webpack-dev-server 3.1.14 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/README.md +14 -27
- package/bin/options.js +43 -31
- package/bin/webpack-dev-server.js +76 -249
- package/client/index.bundle.js +1 -1
- package/client/index.js +92 -42
- package/client/live.bundle.js +3 -3
- package/client/overlay.js +18 -21
- package/client/socket.js +4 -5
- package/client/sockjs.bundle.js +1 -1
- package/client/webpack.config.js +14 -0
- package/lib/Server.js +798 -683
- package/lib/options.json +40 -35
- package/lib/utils/addEntries.js +32 -11
- package/lib/utils/colors.js +22 -0
- package/lib/utils/createCertificate.js +14 -17
- package/lib/utils/createConfig.js +208 -0
- package/lib/utils/createDomain.js +7 -13
- package/lib/utils/createLogger.js +2 -5
- package/lib/utils/defaultTo.js +7 -0
- package/lib/utils/findPort.js +35 -0
- package/lib/utils/getVersions.js +12 -0
- package/lib/utils/runBonjour.js +21 -0
- package/lib/utils/status.js +62 -0
- package/lib/utils/tryParseInt.js +11 -0
- package/lib/utils/updateCompiler.js +67 -0
- package/package.json +72 -52
- package/bin/utils.js +0 -114
package/lib/Server.js
CHANGED
|
@@ -4,10 +4,7 @@
|
|
|
4
4
|
import/order,
|
|
5
5
|
no-shadow,
|
|
6
6
|
no-undefined,
|
|
7
|
-
func-names
|
|
8
|
-
multiline-ternary,
|
|
9
|
-
array-bracket-spacing,
|
|
10
|
-
space-before-function-paren
|
|
7
|
+
func-names
|
|
11
8
|
*/
|
|
12
9
|
const fs = require('fs');
|
|
13
10
|
const path = require('path');
|
|
@@ -17,7 +14,6 @@ const tls = require('tls');
|
|
|
17
14
|
const url = require('url');
|
|
18
15
|
const http = require('http');
|
|
19
16
|
const https = require('https');
|
|
20
|
-
const spdy = require('spdy');
|
|
21
17
|
const sockjs = require('sockjs');
|
|
22
18
|
|
|
23
19
|
const semver = require('semver');
|
|
@@ -37,6 +33,7 @@ const historyApiFallback = require('connect-history-api-fallback');
|
|
|
37
33
|
const webpack = require('webpack');
|
|
38
34
|
const webpackDevMiddleware = require('webpack-dev-middleware');
|
|
39
35
|
|
|
36
|
+
const updateCompiler = require('./utils/updateCompiler');
|
|
40
37
|
const createLogger = require('./utils/createLogger');
|
|
41
38
|
const createCertificate = require('./utils/createCertificate');
|
|
42
39
|
|
|
@@ -53,7 +50,11 @@ const schema = require('./options.json');
|
|
|
53
50
|
SockjsSession.prototype.decorateConnection = function(req) {
|
|
54
51
|
decorateConnection.call(this, req);
|
|
55
52
|
const connection = this.connection;
|
|
56
|
-
if (
|
|
53
|
+
if (
|
|
54
|
+
connection.headers &&
|
|
55
|
+
!('origin' in connection.headers) &&
|
|
56
|
+
'origin' in req.headers
|
|
57
|
+
) {
|
|
57
58
|
connection.headers.origin = req.headers.origin;
|
|
58
59
|
}
|
|
59
60
|
};
|
|
@@ -68,849 +69,963 @@ if (semver.satisfies(process.version, '8.6.0 - 9')) {
|
|
|
68
69
|
tls.DEFAULT_ECDH_CURVE = 'auto';
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
class Server {
|
|
73
|
+
static get DEFAULT_STATS() {
|
|
74
|
+
return {
|
|
75
|
+
all: false,
|
|
76
|
+
hash: true,
|
|
77
|
+
assets: true,
|
|
78
|
+
warnings: true,
|
|
79
|
+
errors: true,
|
|
80
|
+
errorDetails: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
constructor(compiler, options = {}, _log) {
|
|
85
|
+
this.log = _log || createLogger(options);
|
|
82
86
|
|
|
83
|
-
|
|
87
|
+
validateOptions(schema, options, 'webpack Dev Server');
|
|
84
88
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
if (options.lazy && !options.filename) {
|
|
90
|
+
throw new Error("'filename' option must be set in lazy mode.");
|
|
91
|
+
}
|
|
88
92
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
// if the user enables http2, we can safely enable https
|
|
94
|
+
if (options.http2 && !options.https) {
|
|
95
|
+
options.https = true;
|
|
96
|
+
}
|
|
92
97
|
|
|
93
|
-
|
|
94
|
-
this.clientLogLevel = options.clientLogLevel;
|
|
98
|
+
updateCompiler(compiler, options);
|
|
95
99
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
this.disableHostCheck = !!options.disableHostCheck;
|
|
100
|
+
this.originalStats =
|
|
101
|
+
options.stats && Object.keys(options.stats).length ? options.stats : {};
|
|
99
102
|
|
|
100
|
-
|
|
103
|
+
this.hot = options.hot || options.hotOnly;
|
|
104
|
+
this.headers = options.headers;
|
|
105
|
+
this.progress = options.progress;
|
|
101
106
|
|
|
102
|
-
|
|
103
|
-
this.contentBaseWatchers = [];
|
|
107
|
+
this.serveIndex = options.serveIndex;
|
|
104
108
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
this.sockWrite(this.sockets, 'invalid');
|
|
108
|
-
};
|
|
109
|
+
this.clientOverlay = options.overlay;
|
|
110
|
+
this.clientLogLevel = options.clientLogLevel;
|
|
109
111
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
percent = Math.floor(percent * 100);
|
|
112
|
+
this.publicHost = options.public;
|
|
113
|
+
this.allowedHosts = options.allowedHosts;
|
|
114
|
+
this.disableHostCheck = !!options.disableHostCheck;
|
|
114
115
|
|
|
115
|
-
|
|
116
|
-
msg = 'Compilation completed';
|
|
117
|
-
}
|
|
116
|
+
this.sockets = [];
|
|
118
117
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
this.watchOptions = options.watchOptions || {};
|
|
119
|
+
this.contentBaseWatchers = [];
|
|
120
|
+
// Replace leading and trailing slashes to normalize path
|
|
121
|
+
this.sockPath = `/${
|
|
122
|
+
options.sockPath
|
|
123
|
+
? options.sockPath.replace(/^\/|\/$/g, '')
|
|
124
|
+
: 'sockjs-node'
|
|
125
|
+
}`;
|
|
122
126
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
127
|
+
// Listening for events
|
|
128
|
+
const invalidPlugin = () => {
|
|
129
|
+
this.sockWrite(this.sockets, 'invalid');
|
|
130
|
+
};
|
|
126
131
|
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
if (this.progress) {
|
|
133
|
+
const progressPlugin = new webpack.ProgressPlugin(
|
|
134
|
+
(percent, msg, addInfo) => {
|
|
135
|
+
percent = Math.floor(percent * 100);
|
|
129
136
|
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
if (percent === 100) {
|
|
138
|
+
msg = 'Compilation completed';
|
|
139
|
+
}
|
|
132
140
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
this._sendStats(this.sockets, stats.toJson(STATS));
|
|
137
|
-
this._stats = stats;
|
|
138
|
-
});
|
|
139
|
-
};
|
|
141
|
+
if (addInfo) {
|
|
142
|
+
msg = `${msg} (${addInfo})`;
|
|
143
|
+
}
|
|
140
144
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
addHooks(compiler);
|
|
145
|
-
}
|
|
145
|
+
this.sockWrite(this.sockets, 'progress-update', { percent, msg });
|
|
146
|
+
}
|
|
147
|
+
);
|
|
146
148
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const app = this.app = new express();
|
|
149
|
+
progressPlugin.apply(compiler);
|
|
150
|
+
}
|
|
150
151
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
express.static.mime.types.wasm = 'application/wasm';
|
|
152
|
+
const addHooks = (compiler) => {
|
|
153
|
+
const { compile, invalid, done } = compiler.hooks;
|
|
154
154
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
compile.tap('webpack-dev-server', invalidPlugin);
|
|
156
|
+
invalid.tap('webpack-dev-server', invalidPlugin);
|
|
157
|
+
done.tap('webpack-dev-server', (stats) => {
|
|
158
|
+
this._sendStats(this.sockets, this.getStats(stats));
|
|
159
|
+
this._stats = stats;
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (compiler.compilers) {
|
|
164
|
+
compiler.compilers.forEach(addHooks);
|
|
165
|
+
} else {
|
|
166
|
+
addHooks(compiler);
|
|
158
167
|
}
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
// Init express server
|
|
170
|
+
// eslint-disable-next-line
|
|
171
|
+
const app = (this.app = new express());
|
|
162
172
|
|
|
163
|
-
|
|
173
|
+
// ref: https://github.com/webpack/webpack-dev-server/issues/1575
|
|
174
|
+
// ref: https://github.com/webpack/webpack-dev-server/issues/1724
|
|
175
|
+
// remove this when send@^0.16.3
|
|
176
|
+
if (express.static && express.static.mime && express.static.mime.types) {
|
|
177
|
+
express.static.mime.types.wasm = 'application/wasm';
|
|
178
|
+
}
|
|
164
179
|
|
|
165
|
-
|
|
166
|
-
|
|
180
|
+
app.all('*', (req, res, next) => {
|
|
181
|
+
if (this.checkHost(req.headers)) {
|
|
182
|
+
return next();
|
|
183
|
+
}
|
|
167
184
|
|
|
168
|
-
|
|
169
|
-
|
|
185
|
+
res.send('Invalid Host header');
|
|
186
|
+
});
|
|
170
187
|
|
|
171
|
-
|
|
172
|
-
path.join(__dirname, '..', 'client', 'live.bundle.js')
|
|
173
|
-
).pipe(res);
|
|
174
|
-
});
|
|
188
|
+
const wdmOptions = { logLevel: this.log.options.level };
|
|
175
189
|
|
|
176
|
-
|
|
177
|
-
|
|
190
|
+
// middleware for serving webpack bundle
|
|
191
|
+
this.middleware = webpackDevMiddleware(
|
|
192
|
+
compiler,
|
|
193
|
+
Object.assign({}, options, wdmOptions)
|
|
194
|
+
);
|
|
178
195
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
).pipe(res);
|
|
182
|
-
});
|
|
196
|
+
app.get('/__webpack_dev_server__/live.bundle.js', (req, res) => {
|
|
197
|
+
res.setHeader('Content-Type', 'application/javascript');
|
|
183
198
|
|
|
184
|
-
|
|
185
|
-
|
|
199
|
+
fs.createReadStream(
|
|
200
|
+
path.join(__dirname, '..', 'client', 'live.bundle.js')
|
|
201
|
+
).pipe(res);
|
|
202
|
+
});
|
|
186
203
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
).pipe(res);
|
|
190
|
-
});
|
|
204
|
+
app.get('/__webpack_dev_server__/sockjs.bundle.js', (req, res) => {
|
|
205
|
+
res.setHeader('Content-Type', 'application/javascript');
|
|
191
206
|
|
|
192
|
-
|
|
193
|
-
|
|
207
|
+
fs.createReadStream(
|
|
208
|
+
path.join(__dirname, '..', 'client', 'sockjs.bundle.js')
|
|
209
|
+
).pipe(res);
|
|
210
|
+
});
|
|
194
211
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
).pipe(res);
|
|
198
|
-
});
|
|
212
|
+
app.get('/webpack-dev-server.js', (req, res) => {
|
|
213
|
+
res.setHeader('Content-Type', 'application/javascript');
|
|
199
214
|
|
|
200
|
-
|
|
201
|
-
|
|
215
|
+
fs.createReadStream(
|
|
216
|
+
path.join(__dirname, '..', 'client', 'index.bundle.js')
|
|
217
|
+
).pipe(res);
|
|
218
|
+
});
|
|
202
219
|
|
|
203
|
-
|
|
204
|
-
'
|
|
205
|
-
);
|
|
220
|
+
app.get('/webpack-dev-server/*', (req, res) => {
|
|
221
|
+
res.setHeader('Content-Type', 'text/html');
|
|
206
222
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
223
|
+
fs.createReadStream(
|
|
224
|
+
path.join(__dirname, '..', 'client', 'live.html')
|
|
225
|
+
).pipe(res);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
app.get('/webpack-dev-server', (req, res) => {
|
|
229
|
+
res.setHeader('Content-Type', 'text/html');
|
|
210
230
|
|
|
211
|
-
|
|
231
|
+
res.write(
|
|
232
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8"/></head><body>'
|
|
233
|
+
);
|
|
212
234
|
|
|
213
|
-
|
|
214
|
-
|
|
235
|
+
const outputPath = this.middleware.getFilenameFromUrl(
|
|
236
|
+
options.publicPath || '/'
|
|
237
|
+
);
|
|
215
238
|
|
|
216
|
-
|
|
239
|
+
const filesystem = this.middleware.fileSystem;
|
|
217
240
|
|
|
218
|
-
|
|
219
|
-
const
|
|
241
|
+
function writeDirectory(baseUrl, basePath) {
|
|
242
|
+
const content = filesystem.readdirSync(basePath);
|
|
220
243
|
|
|
221
|
-
|
|
222
|
-
res.write('<li><a href="');
|
|
223
|
-
res.write(baseUrl + item);
|
|
224
|
-
res.write('">');
|
|
225
|
-
res.write(item);
|
|
226
|
-
res.write('</a></li>');
|
|
244
|
+
res.write('<ul>');
|
|
227
245
|
|
|
228
|
-
|
|
229
|
-
|
|
246
|
+
content.forEach((item) => {
|
|
247
|
+
const p = `${basePath}/${item}`;
|
|
230
248
|
|
|
249
|
+
if (filesystem.statSync(p).isFile()) {
|
|
231
250
|
res.write('<li><a href="');
|
|
232
|
-
res.write(baseUrl +
|
|
251
|
+
res.write(baseUrl + item);
|
|
233
252
|
res.write('">');
|
|
234
|
-
res.write(html);
|
|
235
|
-
res.write('</a> (magic html for ');
|
|
236
253
|
res.write(item);
|
|
237
|
-
res.write('
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
254
|
+
res.write('</a></li>');
|
|
255
|
+
|
|
256
|
+
if (/\.js$/.test(item)) {
|
|
257
|
+
const html = item.substr(0, item.length - 3);
|
|
258
|
+
|
|
259
|
+
res.write('<li><a href="');
|
|
260
|
+
res.write(baseUrl + html);
|
|
261
|
+
res.write('">');
|
|
262
|
+
res.write(html);
|
|
263
|
+
res.write('</a> (magic html for ');
|
|
264
|
+
res.write(item);
|
|
265
|
+
res.write(') (<a href="');
|
|
266
|
+
res.write(
|
|
267
|
+
baseUrl.replace(
|
|
268
|
+
// eslint-disable-next-line
|
|
269
|
+
/(^(https?:\/\/[^\/]+)?\/)/,
|
|
270
|
+
'$1webpack-dev-server/'
|
|
271
|
+
) + html
|
|
272
|
+
);
|
|
273
|
+
res.write('">webpack-dev-server</a>)</li>');
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
res.write('<li>');
|
|
277
|
+
res.write(item);
|
|
278
|
+
res.write('<br>');
|
|
251
279
|
|
|
252
|
-
|
|
280
|
+
writeDirectory(`${baseUrl + item}/`, p);
|
|
253
281
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
282
|
+
res.write('</li>');
|
|
283
|
+
}
|
|
284
|
+
});
|
|
257
285
|
|
|
258
|
-
|
|
259
|
-
|
|
286
|
+
res.write('</ul>');
|
|
287
|
+
}
|
|
260
288
|
|
|
261
|
-
|
|
289
|
+
writeDirectory(options.publicPath || '/', outputPath);
|
|
262
290
|
|
|
263
|
-
|
|
264
|
-
|
|
291
|
+
res.end('</body></html>');
|
|
292
|
+
});
|
|
265
293
|
|
|
266
|
-
|
|
294
|
+
let contentBase;
|
|
267
295
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
296
|
+
if (options.contentBase !== undefined) {
|
|
297
|
+
contentBase = options.contentBase;
|
|
298
|
+
} else {
|
|
299
|
+
contentBase = process.cwd();
|
|
300
|
+
}
|
|
273
301
|
|
|
274
|
-
|
|
275
|
-
|
|
302
|
+
// Keep track of websocket proxies for external websocket upgrade.
|
|
303
|
+
const websocketProxies = [];
|
|
276
304
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
// For backwards compatibility reasons.
|
|
300
|
-
const correctedContext = context
|
|
301
|
-
.replace(/^\*$/, '**')
|
|
302
|
-
.replace(/\/\*$/, '');
|
|
303
|
-
|
|
304
|
-
if (typeof options.proxy[context] === 'string') {
|
|
305
|
-
proxyOptions = {
|
|
306
|
-
context: correctedContext,
|
|
307
|
-
target: options.proxy[context]
|
|
308
|
-
};
|
|
305
|
+
const features = {
|
|
306
|
+
compress: () => {
|
|
307
|
+
if (options.compress) {
|
|
308
|
+
// Enable gzip compression.
|
|
309
|
+
app.use(compress());
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
proxy: () => {
|
|
313
|
+
if (options.proxy) {
|
|
314
|
+
/**
|
|
315
|
+
* Assume a proxy configuration specified as:
|
|
316
|
+
* proxy: {
|
|
317
|
+
* 'context': { options }
|
|
318
|
+
* }
|
|
319
|
+
* OR
|
|
320
|
+
* proxy: {
|
|
321
|
+
* 'context': 'target'
|
|
322
|
+
* }
|
|
323
|
+
*/
|
|
324
|
+
if (!Array.isArray(options.proxy)) {
|
|
325
|
+
if (Object.prototype.hasOwnProperty.call(options.proxy, 'target')) {
|
|
326
|
+
options.proxy = [options.proxy];
|
|
309
327
|
} else {
|
|
310
|
-
|
|
311
|
-
|
|
328
|
+
options.proxy = Object.keys(options.proxy).map((context) => {
|
|
329
|
+
let proxyOptions;
|
|
330
|
+
// For backwards compatibility reasons.
|
|
331
|
+
const correctedContext = context
|
|
332
|
+
.replace(/^\*$/, '**')
|
|
333
|
+
.replace(/\/\*$/, '');
|
|
334
|
+
|
|
335
|
+
if (typeof options.proxy[context] === 'string') {
|
|
336
|
+
proxyOptions = {
|
|
337
|
+
context: correctedContext,
|
|
338
|
+
target: options.proxy[context],
|
|
339
|
+
};
|
|
340
|
+
} else {
|
|
341
|
+
proxyOptions = Object.assign({}, options.proxy[context]);
|
|
342
|
+
proxyOptions.context = correctedContext;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
proxyOptions.logLevel = proxyOptions.logLevel || 'warn';
|
|
346
|
+
|
|
347
|
+
return proxyOptions;
|
|
348
|
+
});
|
|
312
349
|
}
|
|
313
|
-
|
|
314
|
-
proxyOptions.logLevel = proxyOptions.logLevel || 'warn';
|
|
315
|
-
|
|
316
|
-
return proxyOptions;
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const getProxyMiddleware = (proxyConfig) => {
|
|
321
|
-
const context = proxyConfig.context || proxyConfig.path;
|
|
322
|
-
// It is possible to use the `bypass` method without a `target`.
|
|
323
|
-
// However, the proxy middleware has no use in this case, and will fail to instantiate.
|
|
324
|
-
if (proxyConfig.target) {
|
|
325
|
-
return httpProxyMiddleware(context, proxyConfig);
|
|
326
|
-
}
|
|
327
|
-
};
|
|
328
|
-
/**
|
|
329
|
-
* Assume a proxy configuration specified as:
|
|
330
|
-
* proxy: [
|
|
331
|
-
* {
|
|
332
|
-
* context: ...,
|
|
333
|
-
* ...options...
|
|
334
|
-
* },
|
|
335
|
-
* // or:
|
|
336
|
-
* function() {
|
|
337
|
-
* return {
|
|
338
|
-
* context: ...,
|
|
339
|
-
* ...options...
|
|
340
|
-
* };
|
|
341
|
-
* }
|
|
342
|
-
* ]
|
|
343
|
-
*/
|
|
344
|
-
options.proxy.forEach((proxyConfigOrCallback) => {
|
|
345
|
-
let proxyConfig;
|
|
346
|
-
let proxyMiddleware;
|
|
347
|
-
|
|
348
|
-
if (typeof proxyConfigOrCallback === 'function') {
|
|
349
|
-
proxyConfig = proxyConfigOrCallback();
|
|
350
|
-
} else {
|
|
351
|
-
proxyConfig = proxyConfigOrCallback;
|
|
352
350
|
}
|
|
353
351
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
352
|
+
const getProxyMiddleware = (proxyConfig) => {
|
|
353
|
+
const context = proxyConfig.context || proxyConfig.path;
|
|
354
|
+
// It is possible to use the `bypass` method without a `target`.
|
|
355
|
+
// However, the proxy middleware has no use in this case, and will fail to instantiate.
|
|
356
|
+
if (proxyConfig.target) {
|
|
357
|
+
return httpProxyMiddleware(context, proxyConfig);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
/**
|
|
361
|
+
* Assume a proxy configuration specified as:
|
|
362
|
+
* proxy: [
|
|
363
|
+
* {
|
|
364
|
+
* context: ...,
|
|
365
|
+
* ...options...
|
|
366
|
+
* },
|
|
367
|
+
* // or:
|
|
368
|
+
* function() {
|
|
369
|
+
* return {
|
|
370
|
+
* context: ...,
|
|
371
|
+
* ...options...
|
|
372
|
+
* };
|
|
373
|
+
* }
|
|
374
|
+
* ]
|
|
375
|
+
*/
|
|
376
|
+
options.proxy.forEach((proxyConfigOrCallback) => {
|
|
377
|
+
let proxyConfig;
|
|
378
|
+
let proxyMiddleware;
|
|
359
379
|
|
|
360
|
-
app.use((req, res, next) => {
|
|
361
380
|
if (typeof proxyConfigOrCallback === 'function') {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
proxyConfig = newProxyConfig;
|
|
366
|
-
proxyMiddleware = getProxyMiddleware(proxyConfig);
|
|
367
|
-
}
|
|
381
|
+
proxyConfig = proxyConfigOrCallback();
|
|
382
|
+
} else {
|
|
383
|
+
proxyConfig = proxyConfigOrCallback;
|
|
368
384
|
}
|
|
369
385
|
|
|
370
|
-
|
|
386
|
+
proxyMiddleware = getProxyMiddleware(proxyConfig);
|
|
371
387
|
|
|
372
|
-
|
|
388
|
+
if (proxyConfig.ws) {
|
|
389
|
+
websocketProxies.push(proxyMiddleware);
|
|
390
|
+
}
|
|
373
391
|
|
|
374
|
-
|
|
375
|
-
|
|
392
|
+
app.use((req, res, next) => {
|
|
393
|
+
if (typeof proxyConfigOrCallback === 'function') {
|
|
394
|
+
const newProxyConfig = proxyConfigOrCallback();
|
|
376
395
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
383
|
-
});
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
},
|
|
387
|
-
historyApiFallback: () => {
|
|
388
|
-
if (options.historyApiFallback) {
|
|
389
|
-
const fallback = typeof options.historyApiFallback === 'object'
|
|
390
|
-
? options.historyApiFallback
|
|
391
|
-
: null;
|
|
392
|
-
// Fall back to /index.html if nothing else matches.
|
|
393
|
-
app.use(historyApiFallback(fallback));
|
|
394
|
-
}
|
|
395
|
-
},
|
|
396
|
-
contentBaseFiles: () => {
|
|
397
|
-
if (Array.isArray(contentBase)) {
|
|
398
|
-
contentBase.forEach((item) => {
|
|
399
|
-
app.get('*', express.static(item));
|
|
400
|
-
});
|
|
401
|
-
} else if (/^(https?:)?\/\//.test(contentBase)) {
|
|
402
|
-
this.log.warn(
|
|
403
|
-
'Using a URL as contentBase is deprecated and will be removed in the next major version. Please use the proxy option instead.'
|
|
404
|
-
);
|
|
396
|
+
if (newProxyConfig !== proxyConfig) {
|
|
397
|
+
proxyConfig = newProxyConfig;
|
|
398
|
+
proxyMiddleware = getProxyMiddleware(proxyConfig);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
405
401
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
402
|
+
// - Check if we have a bypass function defined
|
|
403
|
+
// - In case the bypass function is defined we'll retrieve the
|
|
404
|
+
// bypassUrl from it otherwise byPassUrl would be null
|
|
405
|
+
const isByPassFuncDefined =
|
|
406
|
+
typeof proxyConfig.bypass === 'function';
|
|
407
|
+
const bypassUrl = isByPassFuncDefined
|
|
408
|
+
? proxyConfig.bypass(req, res, proxyConfig)
|
|
409
|
+
: null;
|
|
410
|
+
|
|
411
|
+
if (typeof bypassUrl === 'boolean') {
|
|
412
|
+
// skip the proxy
|
|
413
|
+
req.url = null;
|
|
414
|
+
next();
|
|
415
|
+
} else if (typeof bypassUrl === 'string') {
|
|
416
|
+
// byPass to that url
|
|
417
|
+
req.url = bypassUrl;
|
|
418
|
+
next();
|
|
419
|
+
} else if (proxyMiddleware) {
|
|
420
|
+
return proxyMiddleware(req, res, next);
|
|
421
|
+
} else {
|
|
422
|
+
next();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
historyApiFallback: () => {
|
|
429
|
+
if (options.historyApiFallback) {
|
|
430
|
+
const fallback =
|
|
431
|
+
typeof options.historyApiFallback === 'object'
|
|
432
|
+
? options.historyApiFallback
|
|
433
|
+
: null;
|
|
434
|
+
// Fall back to /index.html if nothing else matches.
|
|
435
|
+
app.use(historyApiFallback(fallback));
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
contentBaseFiles: () => {
|
|
439
|
+
if (Array.isArray(contentBase)) {
|
|
440
|
+
contentBase.forEach((item) => {
|
|
441
|
+
app.get('*', express.static(item));
|
|
413
442
|
});
|
|
443
|
+
} else if (/^(https?:)?\/\//.test(contentBase)) {
|
|
444
|
+
this.log.warn(
|
|
445
|
+
'Using a URL as contentBase is deprecated and will be removed in the next major version. Please use the proxy option instead.'
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
this.log.warn(
|
|
449
|
+
'proxy: {\n\t"*": "<your current contentBase configuration>"\n}'
|
|
450
|
+
);
|
|
451
|
+
// Redirect every request to contentBase
|
|
452
|
+
app.get('*', (req, res) => {
|
|
453
|
+
res.writeHead(302, {
|
|
454
|
+
Location: contentBase + req.path + (req._parsedUrl.search || ''),
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
res.end();
|
|
458
|
+
});
|
|
459
|
+
} else if (typeof contentBase === 'number') {
|
|
460
|
+
this.log.warn(
|
|
461
|
+
'Using a number as contentBase is deprecated and will be removed in the next major version. Please use the proxy option instead.'
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
this.log.warn(
|
|
465
|
+
'proxy: {\n\t"*": "//localhost:<your current contentBase configuration>"\n}'
|
|
466
|
+
);
|
|
467
|
+
// Redirect every request to the port contentBase
|
|
468
|
+
app.get('*', (req, res) => {
|
|
469
|
+
res.writeHead(302, {
|
|
470
|
+
Location: `//localhost:${contentBase}${req.path}${req._parsedUrl
|
|
471
|
+
.search || ''}`,
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
res.end();
|
|
475
|
+
});
|
|
476
|
+
} else {
|
|
477
|
+
// route content request
|
|
478
|
+
app.get('*', express.static(contentBase, options.staticOptions));
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
contentBaseIndex: () => {
|
|
482
|
+
if (Array.isArray(contentBase)) {
|
|
483
|
+
contentBase.forEach((item) => {
|
|
484
|
+
app.get('*', serveIndex(item));
|
|
485
|
+
});
|
|
486
|
+
} else if (
|
|
487
|
+
!/^(https?:)?\/\//.test(contentBase) &&
|
|
488
|
+
typeof contentBase !== 'number'
|
|
489
|
+
) {
|
|
490
|
+
app.get('*', serveIndex(contentBase));
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
watchContentBase: () => {
|
|
494
|
+
if (
|
|
495
|
+
/^(https?:)?\/\//.test(contentBase) ||
|
|
496
|
+
typeof contentBase === 'number'
|
|
497
|
+
) {
|
|
498
|
+
throw new Error('Watching remote files is not supported.');
|
|
499
|
+
} else if (Array.isArray(contentBase)) {
|
|
500
|
+
contentBase.forEach((item) => {
|
|
501
|
+
this._watch(item);
|
|
502
|
+
});
|
|
503
|
+
} else {
|
|
504
|
+
this._watch(contentBase);
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
before: () => {
|
|
508
|
+
if (typeof options.before === 'function') {
|
|
509
|
+
options.before(app, this, compiler);
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
middleware: () => {
|
|
513
|
+
// include our middleware to ensure
|
|
514
|
+
// it is able to handle '/index.html' request after redirect
|
|
515
|
+
app.use(this.middleware);
|
|
516
|
+
},
|
|
517
|
+
after: () => {
|
|
518
|
+
if (typeof options.after === 'function') {
|
|
519
|
+
options.after(app, this, compiler);
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
headers: () => {
|
|
523
|
+
app.all('*', this.setContentHeaders.bind(this));
|
|
524
|
+
},
|
|
525
|
+
magicHtml: () => {
|
|
526
|
+
app.get('*', this.serveMagicHtml.bind(this));
|
|
527
|
+
},
|
|
528
|
+
setup: () => {
|
|
529
|
+
if (typeof options.setup === 'function') {
|
|
530
|
+
this.log.warn(
|
|
531
|
+
'The `setup` option is deprecated and will be removed in v4. Please update your config to use `before`'
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
options.setup(app, this);
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
};
|
|
414
538
|
|
|
415
|
-
|
|
416
|
-
});
|
|
417
|
-
} else if (typeof contentBase === 'number') {
|
|
418
|
-
this.log.warn(
|
|
419
|
-
'Using a number as contentBase is deprecated and will be removed in the next major version. Please use the proxy option instead.'
|
|
420
|
-
);
|
|
539
|
+
const defaultFeatures = ['setup', 'before', 'headers', 'middleware'];
|
|
421
540
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
// Redirect every request to the port contentBase
|
|
426
|
-
app.get('*', (req, res) => {
|
|
427
|
-
res.writeHead(302, {
|
|
428
|
-
Location: `//localhost:${contentBase}${req.path}${req._parsedUrl.search || ''}`
|
|
429
|
-
});
|
|
541
|
+
if (options.proxy) {
|
|
542
|
+
defaultFeatures.push('proxy', 'middleware');
|
|
543
|
+
}
|
|
430
544
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
// route content request
|
|
435
|
-
app.get('*', express.static(contentBase, options.staticOptions));
|
|
436
|
-
}
|
|
437
|
-
},
|
|
438
|
-
contentBaseIndex: () => {
|
|
439
|
-
if (Array.isArray(contentBase)) {
|
|
440
|
-
contentBase.forEach((item) => {
|
|
441
|
-
app.get('*', serveIndex(item));
|
|
442
|
-
});
|
|
443
|
-
} else if (
|
|
444
|
-
!/^(https?:)?\/\//.test(contentBase) &&
|
|
445
|
-
typeof contentBase !== 'number'
|
|
446
|
-
) {
|
|
447
|
-
app.get('*', serveIndex(contentBase));
|
|
448
|
-
}
|
|
449
|
-
},
|
|
450
|
-
watchContentBase: () => {
|
|
451
|
-
if (
|
|
452
|
-
/^(https?:)?\/\//.test(contentBase) ||
|
|
453
|
-
typeof contentBase === 'number'
|
|
454
|
-
) {
|
|
455
|
-
throw new Error('Watching remote files is not supported.');
|
|
456
|
-
} else if (Array.isArray(contentBase)) {
|
|
457
|
-
contentBase.forEach((item) => {
|
|
458
|
-
this._watch(item);
|
|
459
|
-
});
|
|
460
|
-
} else {
|
|
461
|
-
this._watch(contentBase);
|
|
462
|
-
}
|
|
463
|
-
},
|
|
464
|
-
before: () => {
|
|
465
|
-
if (typeof options.before === 'function') {
|
|
466
|
-
options.before(app, this);
|
|
467
|
-
}
|
|
468
|
-
},
|
|
469
|
-
middleware: () => {
|
|
470
|
-
// include our middleware to ensure
|
|
471
|
-
// it is able to handle '/index.html' request after redirect
|
|
472
|
-
app.use(this.middleware);
|
|
473
|
-
},
|
|
474
|
-
after: () => {
|
|
475
|
-
if (typeof options.after === 'function') {
|
|
476
|
-
options.after(app, this);
|
|
477
|
-
}
|
|
478
|
-
},
|
|
479
|
-
headers: () => {
|
|
480
|
-
app.all('*', this.setContentHeaders.bind(this));
|
|
481
|
-
},
|
|
482
|
-
magicHtml: () => {
|
|
483
|
-
app.get('*', this.serveMagicHtml.bind(this));
|
|
484
|
-
},
|
|
485
|
-
setup: () => {
|
|
486
|
-
if (typeof options.setup === 'function') {
|
|
487
|
-
this.log.warn(
|
|
488
|
-
'The `setup` option is deprecated and will be removed in v3. Please update your config to use `before`'
|
|
489
|
-
);
|
|
545
|
+
if (contentBase !== false) {
|
|
546
|
+
defaultFeatures.push('contentBaseFiles');
|
|
547
|
+
}
|
|
490
548
|
|
|
491
|
-
|
|
549
|
+
if (options.watchContentBase) {
|
|
550
|
+
defaultFeatures.push('watchContentBase');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (options.historyApiFallback) {
|
|
554
|
+
defaultFeatures.push('historyApiFallback', 'middleware');
|
|
555
|
+
|
|
556
|
+
if (contentBase !== false) {
|
|
557
|
+
defaultFeatures.push('contentBaseFiles');
|
|
492
558
|
}
|
|
493
559
|
}
|
|
494
|
-
};
|
|
495
560
|
|
|
496
|
-
|
|
497
|
-
'setup',
|
|
498
|
-
'before',
|
|
499
|
-
'headers',
|
|
500
|
-
'middleware'
|
|
501
|
-
];
|
|
561
|
+
defaultFeatures.push('magicHtml');
|
|
502
562
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
}
|
|
563
|
+
// checking if it's set to true or not set (Default : undefined => true)
|
|
564
|
+
this.serveIndex = this.serveIndex || this.serveIndex === undefined;
|
|
506
565
|
|
|
507
|
-
|
|
508
|
-
defaultFeatures.push('contentBaseFiles');
|
|
509
|
-
}
|
|
566
|
+
const shouldHandleServeIndex = contentBase && this.serveIndex;
|
|
510
567
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
568
|
+
if (shouldHandleServeIndex) {
|
|
569
|
+
defaultFeatures.push('contentBaseIndex');
|
|
570
|
+
}
|
|
514
571
|
|
|
515
|
-
|
|
516
|
-
|
|
572
|
+
// compress is placed last and uses unshift so that it will be the first middleware used
|
|
573
|
+
if (options.compress) {
|
|
574
|
+
defaultFeatures.unshift('compress');
|
|
575
|
+
}
|
|
517
576
|
|
|
518
|
-
if (
|
|
519
|
-
defaultFeatures.push('
|
|
577
|
+
if (options.after) {
|
|
578
|
+
defaultFeatures.push('after');
|
|
520
579
|
}
|
|
521
|
-
}
|
|
522
580
|
|
|
523
|
-
|
|
581
|
+
(options.features || defaultFeatures).forEach((feature) => {
|
|
582
|
+
features[feature]();
|
|
583
|
+
});
|
|
524
584
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
585
|
+
if (options.https) {
|
|
586
|
+
// for keep supporting CLI parameters
|
|
587
|
+
if (typeof options.https === 'boolean') {
|
|
588
|
+
options.https = {
|
|
589
|
+
ca: options.ca,
|
|
590
|
+
pfx: options.pfx,
|
|
591
|
+
key: options.key,
|
|
592
|
+
cert: options.cert,
|
|
593
|
+
passphrase: options.pfxPassphrase,
|
|
594
|
+
requestCert: options.requestCert || false,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
532
597
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
598
|
+
for (const property of ['ca', 'pfx', 'key', 'cert']) {
|
|
599
|
+
const value = options.https[property];
|
|
600
|
+
const isBuffer = value instanceof Buffer;
|
|
536
601
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
if (options.https) {
|
|
542
|
-
// for keep supporting CLI parameters
|
|
543
|
-
if (typeof options.https === 'boolean') {
|
|
544
|
-
options.https = {
|
|
545
|
-
ca: options.ca,
|
|
546
|
-
pfx: options.pfx,
|
|
547
|
-
key: options.key,
|
|
548
|
-
cert: options.cert,
|
|
549
|
-
passphrase: options.pfxPassphrase,
|
|
550
|
-
requestCert: options.requestCert || false
|
|
551
|
-
};
|
|
552
|
-
}
|
|
602
|
+
if (value && !isBuffer) {
|
|
603
|
+
let stats = null;
|
|
553
604
|
|
|
554
|
-
|
|
605
|
+
try {
|
|
606
|
+
stats = fs.lstatSync(value).isFile();
|
|
607
|
+
} catch (error) {
|
|
608
|
+
// ignore error
|
|
609
|
+
}
|
|
555
610
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
611
|
+
if (stats) {
|
|
612
|
+
// It is file
|
|
613
|
+
options.https[property] = fs.readFileSync(path.resolve(value));
|
|
614
|
+
} else {
|
|
615
|
+
options.https[property] = value;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
560
619
|
|
|
561
|
-
let
|
|
620
|
+
let fakeCert;
|
|
562
621
|
|
|
563
|
-
if (
|
|
564
|
-
|
|
565
|
-
|
|
622
|
+
if (!options.https.key || !options.https.cert) {
|
|
623
|
+
// Use a self-signed certificate if no certificate was configured.
|
|
624
|
+
// Cycle certs every 24 hours
|
|
625
|
+
const certPath = path.join(__dirname, '../ssl/server.pem');
|
|
566
626
|
|
|
567
|
-
|
|
627
|
+
let certExists = fs.existsSync(certPath);
|
|
568
628
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
629
|
+
if (certExists) {
|
|
630
|
+
const certTtl = 1000 * 60 * 60 * 24;
|
|
631
|
+
const certStat = fs.statSync(certPath);
|
|
572
632
|
|
|
573
|
-
|
|
633
|
+
const now = new Date();
|
|
574
634
|
|
|
575
|
-
|
|
635
|
+
// cert is more than 30 days old, kill it with fire
|
|
636
|
+
if ((now - certStat.ctime) / certTtl > 30) {
|
|
637
|
+
this.log.info(
|
|
638
|
+
'SSL Certificate is more than 30 days old. Removing.'
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
del.sync([certPath], { force: true });
|
|
642
|
+
|
|
643
|
+
certExists = false;
|
|
644
|
+
}
|
|
576
645
|
}
|
|
577
|
-
}
|
|
578
646
|
|
|
579
|
-
|
|
580
|
-
|
|
647
|
+
if (!certExists) {
|
|
648
|
+
this.log.info('Generating SSL Certificate');
|
|
581
649
|
|
|
582
|
-
|
|
583
|
-
{ name: 'commonName', value: 'localhost' }
|
|
584
|
-
];
|
|
650
|
+
const attrs = [{ name: 'commonName', value: 'localhost' }];
|
|
585
651
|
|
|
586
|
-
|
|
652
|
+
const pems = createCertificate(attrs);
|
|
587
653
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
654
|
+
fs.writeFileSync(certPath, pems.private + pems.cert, {
|
|
655
|
+
encoding: 'utf8',
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
fakeCert = fs.readFileSync(certPath);
|
|
593
660
|
}
|
|
594
661
|
|
|
595
|
-
|
|
596
|
-
|
|
662
|
+
options.https.key = options.https.key || fakeCert;
|
|
663
|
+
options.https.cert = options.https.cert || fakeCert;
|
|
597
664
|
|
|
598
|
-
|
|
599
|
-
|
|
665
|
+
// Only prevent HTTP/2 if http2 is explicitly set to false
|
|
666
|
+
const isHttp2 = options.http2 !== false;
|
|
600
667
|
|
|
601
|
-
|
|
602
|
-
options.https.spdy
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
668
|
+
// note that options.spdy never existed. The user was able
|
|
669
|
+
// to set options.https.spdy before, though it was not in the
|
|
670
|
+
// docs. Keep options.https.spdy if the user sets it for
|
|
671
|
+
// backwards compatability, but log a deprecation warning.
|
|
672
|
+
if (options.https.spdy) {
|
|
673
|
+
// for backwards compatability: if options.https.spdy was passed in before,
|
|
674
|
+
// it was not altered in any way
|
|
675
|
+
this.log.warn(
|
|
676
|
+
'Providing custom spdy server options is deprecated and will be removed in the next major version.'
|
|
677
|
+
);
|
|
678
|
+
} else {
|
|
679
|
+
// if the normal https server gets this option, it will not affect it.
|
|
680
|
+
options.https.spdy = {
|
|
681
|
+
protocols: ['h2', 'http/1.1'],
|
|
682
|
+
};
|
|
683
|
+
}
|
|
606
684
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
685
|
+
// `spdy` is effectively unmaintained, and as a consequence of an
|
|
686
|
+
// implementation that extensively relies on Node’s non-public APIs, broken
|
|
687
|
+
// on Node 10 and above. In those cases, only https will be used for now.
|
|
688
|
+
// Once express supports Node's built-in HTTP/2 support, migrating over to
|
|
689
|
+
// that should be the best way to go.
|
|
690
|
+
// The relevant issues are:
|
|
691
|
+
// - https://github.com/nodejs/node/issues/21665
|
|
692
|
+
// - https://github.com/webpack/webpack-dev-server/issues/1449
|
|
693
|
+
// - https://github.com/expressjs/express/issues/3388
|
|
694
|
+
if (semver.gte(process.version, '10.0.0') || !isHttp2) {
|
|
695
|
+
if (options.http2) {
|
|
696
|
+
// the user explicitly requested http2 but is not getting it because
|
|
697
|
+
// of the node version.
|
|
698
|
+
this.log.warn(
|
|
699
|
+
'HTTP/2 is currently unsupported for Node 10.0.0 and above, but will be supported once Express supports it'
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
this.listeningApp = https.createServer(options.https, app);
|
|
703
|
+
} else {
|
|
704
|
+
/* eslint-disable global-require */
|
|
705
|
+
// The relevant issues are:
|
|
706
|
+
// https://github.com/spdy-http2/node-spdy/issues/350
|
|
707
|
+
// https://github.com/webpack/webpack-dev-server/issues/1592
|
|
708
|
+
this.listeningApp = require('spdy').createServer(options.https, app);
|
|
709
|
+
/* eslint-enable global-require */
|
|
710
|
+
}
|
|
618
711
|
} else {
|
|
619
|
-
this.listeningApp =
|
|
712
|
+
this.listeningApp = http.createServer(app);
|
|
620
713
|
}
|
|
621
|
-
} else {
|
|
622
|
-
this.listeningApp = http.createServer(app);
|
|
623
|
-
}
|
|
624
714
|
|
|
625
|
-
|
|
715
|
+
killable(this.listeningApp);
|
|
626
716
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
}
|
|
717
|
+
// Proxy websockets without the initial http request
|
|
718
|
+
// https://github.com/chimurai/http-proxy-middleware#external-websocket-upgrade
|
|
719
|
+
websocketProxies.forEach(function(wsProxy) {
|
|
720
|
+
this.listeningApp.on('upgrade', wsProxy.upgrade);
|
|
721
|
+
}, this);
|
|
722
|
+
}
|
|
633
723
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
this.app.use.apply(this.app, arguments);
|
|
637
|
-
};
|
|
724
|
+
getStats(statsObj) {
|
|
725
|
+
const stats = Server.DEFAULT_STATS;
|
|
638
726
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
for (const name in this.headers) { // eslint-disable-line
|
|
642
|
-
res.setHeader(name, this.headers[name]);
|
|
727
|
+
if (this.originalStats.warningsFilter) {
|
|
728
|
+
stats.warningsFilter = this.originalStats.warningsFilter;
|
|
643
729
|
}
|
|
644
|
-
}
|
|
645
730
|
|
|
646
|
-
|
|
647
|
-
}
|
|
731
|
+
return statsObj.toJson(stats);
|
|
732
|
+
}
|
|
648
733
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
return true;
|
|
734
|
+
use() {
|
|
735
|
+
// eslint-disable-next-line
|
|
736
|
+
this.app.use.apply(this.app, arguments);
|
|
653
737
|
}
|
|
654
738
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
739
|
+
setContentHeaders(req, res, next) {
|
|
740
|
+
if (this.headers) {
|
|
741
|
+
// eslint-disable-next-line
|
|
742
|
+
for (const name in this.headers) {
|
|
743
|
+
// eslint-disable-line
|
|
744
|
+
res.setHeader(name, this.headers[name]);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
659
747
|
|
|
660
|
-
|
|
661
|
-
return false;
|
|
748
|
+
next();
|
|
662
749
|
}
|
|
663
750
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
// if hostHeader doesn't have scheme, add // for parsing.
|
|
667
|
-
/^(.+:)?\/\//.test(hostHeader) ? hostHeader : `//${hostHeader}`,
|
|
668
|
-
false,
|
|
669
|
-
true
|
|
670
|
-
).hostname;
|
|
671
|
-
// always allow requests with explicit IPv4 or IPv6-address.
|
|
672
|
-
// A note on IPv6 addresses:
|
|
673
|
-
// hostHeader will always contain the brackets denoting
|
|
674
|
-
// an IPv6-address in URLs,
|
|
675
|
-
// these are removed from the hostname in url.parse(),
|
|
676
|
-
// so we have the pure IPv6-address in hostname.
|
|
677
|
-
if (ip.isV4Format(hostname) || ip.isV6Format(hostname)) {
|
|
678
|
-
return true;
|
|
751
|
+
checkHost(headers) {
|
|
752
|
+
return this.checkHeaders(headers, 'host');
|
|
679
753
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
return
|
|
754
|
+
|
|
755
|
+
checkOrigin(headers) {
|
|
756
|
+
return this.checkHeaders(headers, 'origin');
|
|
683
757
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
if (allowedHost === hostname) return true;
|
|
690
|
-
|
|
691
|
-
// support "." as a subdomain wildcard
|
|
692
|
-
// e.g. ".example.com" will allow "example.com", "www.example.com", "subdomain.example.com", etc
|
|
693
|
-
if (allowedHost[0] === '.') {
|
|
694
|
-
// "example.com"
|
|
695
|
-
if (hostname === allowedHost.substring(1)) {
|
|
696
|
-
return true;
|
|
697
|
-
}
|
|
698
|
-
// "*.example.com"
|
|
699
|
-
if (hostname.endsWith(allowedHost)) {
|
|
700
|
-
return true;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
758
|
+
|
|
759
|
+
checkHeaders(headers, headerToCheck) {
|
|
760
|
+
// allow user to opt-out this security check, at own risk
|
|
761
|
+
if (this.disableHostCheck) {
|
|
762
|
+
return true;
|
|
703
763
|
}
|
|
704
|
-
}
|
|
705
764
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
765
|
+
if (!headerToCheck) {
|
|
766
|
+
headerToCheck = 'host';
|
|
767
|
+
}
|
|
710
768
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
const
|
|
769
|
+
// get the Host header and extract hostname
|
|
770
|
+
// we don't care about port not matching
|
|
771
|
+
const hostHeader = headers[headerToCheck];
|
|
714
772
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
773
|
+
if (!hostHeader) {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
718
776
|
|
|
719
|
-
|
|
777
|
+
// use the node url-parser to retrieve the hostname from the host-header.
|
|
778
|
+
const hostname = url.parse(
|
|
779
|
+
// if hostHeader doesn't have scheme, add // for parsing.
|
|
780
|
+
/^(.+:)?\/\//.test(hostHeader) ? hostHeader : `//${hostHeader}`,
|
|
781
|
+
false,
|
|
782
|
+
true
|
|
783
|
+
).hostname;
|
|
784
|
+
// always allow requests with explicit IPv4 or IPv6-address.
|
|
785
|
+
// A note on IPv6 addresses:
|
|
786
|
+
// hostHeader will always contain the brackets denoting
|
|
787
|
+
// an IPv6-address in URLs,
|
|
788
|
+
// these are removed from the hostname in url.parse(),
|
|
789
|
+
// so we have the pure IPv6-address in hostname.
|
|
790
|
+
if (ip.isV4Format(hostname) || ip.isV6Format(hostname)) {
|
|
720
791
|
return true;
|
|
721
792
|
}
|
|
722
|
-
|
|
793
|
+
// always allow localhost host, for convience
|
|
794
|
+
if (hostname === 'localhost') {
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
// allow if hostname is in allowedHosts
|
|
798
|
+
if (this.allowedHosts && this.allowedHosts.length) {
|
|
799
|
+
for (let hostIdx = 0; hostIdx < this.allowedHosts.length; hostIdx++) {
|
|
800
|
+
const allowedHost = this.allowedHosts[hostIdx];
|
|
723
801
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
};
|
|
727
|
-
|
|
728
|
-
// delegate listen call and init sockjs
|
|
729
|
-
Server.prototype.listen = function (port, hostname, fn) {
|
|
730
|
-
this.hostname = hostname;
|
|
731
|
-
|
|
732
|
-
const returnValue = this.listeningApp.listen(port, hostname, (err) => {
|
|
733
|
-
const socket = sockjs.createServer({
|
|
734
|
-
// Use provided up-to-date sockjs-client
|
|
735
|
-
sockjs_url: '/__webpack_dev_server__/sockjs.bundle.js',
|
|
736
|
-
// Limit useless logs
|
|
737
|
-
log: (severity, line) => {
|
|
738
|
-
if (severity === 'error') {
|
|
739
|
-
this.log.error(line);
|
|
740
|
-
} else {
|
|
741
|
-
this.log.debug(line);
|
|
802
|
+
if (allowedHost === hostname) {
|
|
803
|
+
return true;
|
|
742
804
|
}
|
|
743
|
-
}
|
|
744
|
-
});
|
|
745
805
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
806
|
+
// support "." as a subdomain wildcard
|
|
807
|
+
// e.g. ".example.com" will allow "example.com", "www.example.com", "subdomain.example.com", etc
|
|
808
|
+
if (allowedHost[0] === '.') {
|
|
809
|
+
// "example.com"
|
|
810
|
+
if (hostname === allowedHost.substring(1)) {
|
|
811
|
+
return true;
|
|
812
|
+
}
|
|
813
|
+
// "*.example.com"
|
|
814
|
+
if (hostname.endsWith(allowedHost)) {
|
|
815
|
+
return true;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
749
818
|
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// allow hostname of listening adress
|
|
822
|
+
if (hostname === this.hostname) {
|
|
823
|
+
return true;
|
|
824
|
+
}
|
|
750
825
|
|
|
751
|
-
|
|
752
|
-
|
|
826
|
+
// also allow public hostname if provided
|
|
827
|
+
if (typeof this.publicHost === 'string') {
|
|
828
|
+
const idxPublic = this.publicHost.indexOf(':');
|
|
753
829
|
|
|
754
|
-
|
|
830
|
+
const publicHostname =
|
|
831
|
+
idxPublic >= 0 ? this.publicHost.substr(0, idxPublic) : this.publicHost;
|
|
755
832
|
|
|
756
|
-
|
|
833
|
+
if (hostname === publicHostname) {
|
|
834
|
+
return true;
|
|
757
835
|
}
|
|
836
|
+
}
|
|
758
837
|
|
|
759
|
-
|
|
838
|
+
// disallow
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
760
841
|
|
|
761
|
-
|
|
762
|
-
|
|
842
|
+
// delegate listen call and init sockjs
|
|
843
|
+
listen(port, hostname, fn) {
|
|
844
|
+
this.hostname = hostname;
|
|
845
|
+
|
|
846
|
+
return this.listeningApp.listen(port, hostname, (err) => {
|
|
847
|
+
const socket = sockjs.createServer({
|
|
848
|
+
// Use provided up-to-date sockjs-client
|
|
849
|
+
sockjs_url: '/__webpack_dev_server__/sockjs.bundle.js',
|
|
850
|
+
// Limit useless logs
|
|
851
|
+
log: (severity, line) => {
|
|
852
|
+
if (severity === 'error') {
|
|
853
|
+
this.log.error(line);
|
|
854
|
+
} else {
|
|
855
|
+
this.log.debug(line);
|
|
856
|
+
}
|
|
857
|
+
},
|
|
858
|
+
});
|
|
763
859
|
|
|
764
|
-
|
|
765
|
-
|
|
860
|
+
socket.on('connection', (connection) => {
|
|
861
|
+
if (!connection) {
|
|
862
|
+
return;
|
|
766
863
|
}
|
|
767
|
-
});
|
|
768
864
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
865
|
+
if (
|
|
866
|
+
!this.checkHost(connection.headers) ||
|
|
867
|
+
!this.checkOrigin(connection.headers)
|
|
868
|
+
) {
|
|
869
|
+
this.sockWrite([connection], 'error', 'Invalid Host/Origin header');
|
|
772
870
|
|
|
773
|
-
|
|
774
|
-
this.sockWrite([ connection ], 'progress', this.progress);
|
|
775
|
-
}
|
|
871
|
+
connection.close();
|
|
776
872
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
}
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
780
875
|
|
|
781
|
-
|
|
782
|
-
this.sockWrite([ connection ], 'log-level', this.clientLogLevel);
|
|
783
|
-
}
|
|
876
|
+
this.sockets.push(connection);
|
|
784
877
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
}
|
|
878
|
+
connection.on('close', () => {
|
|
879
|
+
const idx = this.sockets.indexOf(connection);
|
|
788
880
|
|
|
789
|
-
|
|
790
|
-
|
|
881
|
+
if (idx >= 0) {
|
|
882
|
+
this.sockets.splice(idx, 1);
|
|
883
|
+
}
|
|
884
|
+
});
|
|
791
885
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
886
|
+
if (this.hot) {
|
|
887
|
+
this.sockWrite([connection], 'hot');
|
|
888
|
+
}
|
|
795
889
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
});
|
|
890
|
+
if (this.progress) {
|
|
891
|
+
this.sockWrite([connection], 'progress', this.progress);
|
|
892
|
+
}
|
|
800
893
|
|
|
801
|
-
|
|
802
|
-
|
|
894
|
+
if (this.clientOverlay) {
|
|
895
|
+
this.sockWrite([connection], 'overlay', this.clientOverlay);
|
|
896
|
+
}
|
|
803
897
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
});
|
|
898
|
+
if (this.clientLogLevel) {
|
|
899
|
+
this.sockWrite([connection], 'log-level', this.clientLogLevel);
|
|
900
|
+
}
|
|
808
901
|
|
|
809
|
-
|
|
902
|
+
if (!this._stats) {
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
810
905
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
});
|
|
906
|
+
this._sendStats([connection], this.getStats(this._stats), true);
|
|
907
|
+
});
|
|
814
908
|
|
|
815
|
-
|
|
909
|
+
socket.installHandlers(this.listeningApp, {
|
|
910
|
+
prefix: this.sockPath,
|
|
911
|
+
});
|
|
816
912
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
};
|
|
913
|
+
if (fn) {
|
|
914
|
+
fn.call(this.listeningApp, err);
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
}
|
|
821
918
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
);
|
|
827
|
-
});
|
|
828
|
-
};
|
|
919
|
+
close(cb) {
|
|
920
|
+
this.sockets.forEach((socket) => {
|
|
921
|
+
socket.close();
|
|
922
|
+
});
|
|
829
923
|
|
|
830
|
-
|
|
831
|
-
const _path = req.path;
|
|
924
|
+
this.sockets = [];
|
|
832
925
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
).isFile();
|
|
926
|
+
this.contentBaseWatchers.forEach((watcher) => {
|
|
927
|
+
watcher.close();
|
|
928
|
+
});
|
|
837
929
|
|
|
838
|
-
|
|
839
|
-
return next();
|
|
840
|
-
}
|
|
841
|
-
// Serve a page that executes the javascript
|
|
842
|
-
res.write(
|
|
843
|
-
'<!DOCTYPE html><html><head><meta charset="utf-8"/></head><body><script type="text/javascript" charset="utf-8" src="'
|
|
844
|
-
);
|
|
845
|
-
res.write(_path);
|
|
846
|
-
res.write('.js');
|
|
847
|
-
res.write(req._parsedUrl.search || '');
|
|
930
|
+
this.contentBaseWatchers = [];
|
|
848
931
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
932
|
+
this.listeningApp.kill(() => {
|
|
933
|
+
this.middleware.close(cb);
|
|
934
|
+
});
|
|
852
935
|
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
stats &&
|
|
860
|
-
(!stats.errors || stats.errors.length === 0) &&
|
|
861
|
-
stats.assets &&
|
|
862
|
-
stats.assets.every(asset => !asset.emitted)
|
|
863
|
-
) {
|
|
864
|
-
return this.sockWrite(sockets, 'still-ok');
|
|
936
|
+
|
|
937
|
+
// eslint-disable-next-line
|
|
938
|
+
sockWrite(sockets, type, data) {
|
|
939
|
+
sockets.forEach((socket) => {
|
|
940
|
+
socket.write(JSON.stringify({ type, data }));
|
|
941
|
+
});
|
|
865
942
|
}
|
|
866
943
|
|
|
867
|
-
|
|
944
|
+
serveMagicHtml(req, res, next) {
|
|
945
|
+
const _path = req.path;
|
|
868
946
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
947
|
+
try {
|
|
948
|
+
const isFile = this.middleware.fileSystem
|
|
949
|
+
.statSync(this.middleware.getFilenameFromUrl(`${_path}.js`))
|
|
950
|
+
.isFile();
|
|
951
|
+
|
|
952
|
+
if (!isFile) {
|
|
953
|
+
return next();
|
|
954
|
+
}
|
|
955
|
+
// Serve a page that executes the javascript
|
|
956
|
+
res.write(
|
|
957
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8"/></head><body><script type="text/javascript" charset="utf-8" src="'
|
|
958
|
+
);
|
|
959
|
+
res.write(_path);
|
|
960
|
+
res.write('.js');
|
|
961
|
+
res.write(req._parsedUrl.search || '');
|
|
962
|
+
|
|
963
|
+
res.end('"></script></body></html>');
|
|
964
|
+
} catch (err) {
|
|
965
|
+
return next();
|
|
966
|
+
}
|
|
875
967
|
}
|
|
876
|
-
};
|
|
877
|
-
|
|
878
|
-
Server.prototype._watch = function (watchPath) {
|
|
879
|
-
// duplicate the same massaging of options that watchpack performs
|
|
880
|
-
// https://github.com/webpack/watchpack/blob/master/lib/DirectoryWatcher.js#L49
|
|
881
|
-
// this isn't an elegant solution, but we'll improve it in the future
|
|
882
|
-
const usePolling = this.watchOptions.poll ? true : undefined;
|
|
883
|
-
const interval = typeof this.watchOptions.poll === 'number'
|
|
884
|
-
? this.watchOptions.poll
|
|
885
|
-
: undefined;
|
|
886
|
-
|
|
887
|
-
const options = {
|
|
888
|
-
ignoreInitial: true,
|
|
889
|
-
persistent: true,
|
|
890
|
-
followSymlinks: false,
|
|
891
|
-
depth: 0,
|
|
892
|
-
atomic: false,
|
|
893
|
-
alwaysStat: true,
|
|
894
|
-
ignorePermissionErrors: true,
|
|
895
|
-
ignored: this.watchOptions.ignored,
|
|
896
|
-
usePolling,
|
|
897
|
-
interval
|
|
898
|
-
};
|
|
899
968
|
|
|
900
|
-
|
|
969
|
+
// send stats to a socket or multiple sockets
|
|
970
|
+
_sendStats(sockets, stats, force) {
|
|
971
|
+
if (
|
|
972
|
+
!force &&
|
|
973
|
+
stats &&
|
|
974
|
+
(!stats.errors || stats.errors.length === 0) &&
|
|
975
|
+
stats.assets &&
|
|
976
|
+
stats.assets.every((asset) => !asset.emitted)
|
|
977
|
+
) {
|
|
978
|
+
return this.sockWrite(sockets, 'still-ok');
|
|
979
|
+
}
|
|
901
980
|
|
|
902
|
-
|
|
903
|
-
this.sockWrite(this.sockets, 'content-changed');
|
|
904
|
-
});
|
|
981
|
+
this.sockWrite(sockets, 'hash', stats.hash);
|
|
905
982
|
|
|
906
|
-
|
|
907
|
-
|
|
983
|
+
if (stats.errors.length > 0) {
|
|
984
|
+
this.sockWrite(sockets, 'errors', stats.errors);
|
|
985
|
+
} else if (stats.warnings.length > 0) {
|
|
986
|
+
this.sockWrite(sockets, 'warnings', stats.warnings);
|
|
987
|
+
} else {
|
|
988
|
+
this.sockWrite(sockets, 'ok');
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
_watch(watchPath) {
|
|
993
|
+
// duplicate the same massaging of options that watchpack performs
|
|
994
|
+
// https://github.com/webpack/watchpack/blob/master/lib/DirectoryWatcher.js#L49
|
|
995
|
+
// this isn't an elegant solution, but we'll improve it in the future
|
|
996
|
+
const usePolling = this.watchOptions.poll ? true : undefined;
|
|
997
|
+
const interval =
|
|
998
|
+
typeof this.watchOptions.poll === 'number'
|
|
999
|
+
? this.watchOptions.poll
|
|
1000
|
+
: undefined;
|
|
1001
|
+
|
|
1002
|
+
const options = {
|
|
1003
|
+
ignoreInitial: true,
|
|
1004
|
+
persistent: true,
|
|
1005
|
+
followSymlinks: false,
|
|
1006
|
+
atomic: false,
|
|
1007
|
+
alwaysStat: true,
|
|
1008
|
+
ignorePermissionErrors: true,
|
|
1009
|
+
ignored: this.watchOptions.ignored,
|
|
1010
|
+
usePolling,
|
|
1011
|
+
interval,
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
const watcher = chokidar.watch(watchPath, options);
|
|
1015
|
+
|
|
1016
|
+
watcher.on('change', () => {
|
|
1017
|
+
this.sockWrite(this.sockets, 'content-changed');
|
|
1018
|
+
});
|
|
908
1019
|
|
|
909
|
-
|
|
910
|
-
if (this.middleware) {
|
|
911
|
-
this.middleware.invalidate();
|
|
1020
|
+
this.contentBaseWatchers.push(watcher);
|
|
912
1021
|
}
|
|
913
|
-
|
|
1022
|
+
|
|
1023
|
+
invalidate() {
|
|
1024
|
+
if (this.middleware) {
|
|
1025
|
+
this.middleware.invalidate();
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
914
1029
|
|
|
915
1030
|
// Export this logic,
|
|
916
1031
|
// so that other implementations,
|