@atlaspack/reporter-dev-server 2.12.1-canary.3354

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/src/Server.js ADDED
@@ -0,0 +1,544 @@
1
+ // @flow
2
+
3
+ import type {DevServerOptions, Request, Response} from './types.js.flow';
4
+ import type {
5
+ BuildSuccessEvent,
6
+ BundleGraph,
7
+ FilePath,
8
+ PluginOptions,
9
+ PackagedBundle,
10
+ } from '@atlaspack/types';
11
+ import type {Diagnostic} from '@atlaspack/diagnostic';
12
+ import type {FileSystem} from '@atlaspack/fs';
13
+ import type {HTTPServer, FormattedCodeFrame} from '@atlaspack/utils';
14
+
15
+ import invariant from 'assert';
16
+ import path from 'path';
17
+ import url from 'url';
18
+ import {
19
+ ansiHtml,
20
+ createHTTPServer,
21
+ resolveConfig,
22
+ readConfig,
23
+ prettyDiagnostic,
24
+ relativePath,
25
+ } from '@atlaspack/utils';
26
+ import serverErrors from './serverErrors';
27
+ import fs from 'fs';
28
+ import ejs from 'ejs';
29
+ import connect from 'connect';
30
+ import serveHandler from 'serve-handler';
31
+ import {createProxyMiddleware} from 'http-proxy-middleware';
32
+ import {URL, URLSearchParams} from 'url';
33
+ import launchEditor from 'launch-editor';
34
+ import fresh from 'fresh';
35
+
36
+ export function setHeaders(res: Response) {
37
+ res.setHeader('Access-Control-Allow-Origin', '*');
38
+ res.setHeader(
39
+ 'Access-Control-Allow-Methods',
40
+ 'GET, HEAD, PUT, PATCH, POST, DELETE',
41
+ );
42
+ res.setHeader(
43
+ 'Access-Control-Allow-Headers',
44
+ 'Origin, X-Requested-With, Content-Type, Accept, Content-Type',
45
+ );
46
+ res.setHeader('Cache-Control', 'max-age=0, must-revalidate');
47
+ }
48
+
49
+ const SLASH_REGEX = /\//g;
50
+
51
+ export const SOURCES_ENDPOINT = '/__atlaspack_source_root';
52
+ const EDITOR_ENDPOINT = '/__atlaspack_launch_editor';
53
+ const TEMPLATE_404 = fs.readFileSync(
54
+ path.join(__dirname, 'templates/404.html'),
55
+ 'utf8',
56
+ );
57
+
58
+ const TEMPLATE_500 = fs.readFileSync(
59
+ path.join(__dirname, 'templates/500.html'),
60
+ 'utf8',
61
+ );
62
+ type NextFunction = (req: Request, res: Response, next?: (any) => any) => any;
63
+
64
+ export default class Server {
65
+ pending: boolean;
66
+ pendingRequests: Array<[Request, Response]>;
67
+ middleware: Array<(req: Request, res: Response) => boolean>;
68
+ options: DevServerOptions;
69
+ rootPath: string;
70
+ bundleGraph: BundleGraph<PackagedBundle> | null;
71
+ requestBundle: ?(bundle: PackagedBundle) => Promise<BuildSuccessEvent>;
72
+ errors: Array<{|
73
+ message: string,
74
+ stack: ?string,
75
+ frames: Array<FormattedCodeFrame>,
76
+ hints: Array<string>,
77
+ documentation: string,
78
+ |}> | null;
79
+ stopServer: ?() => Promise<void>;
80
+
81
+ constructor(options: DevServerOptions) {
82
+ this.options = options;
83
+ try {
84
+ this.rootPath = new URL(options.publicUrl).pathname;
85
+ } catch (e) {
86
+ this.rootPath = options.publicUrl;
87
+ }
88
+ this.pending = true;
89
+ this.pendingRequests = [];
90
+ this.middleware = [];
91
+ this.bundleGraph = null;
92
+ this.requestBundle = null;
93
+ this.errors = null;
94
+ }
95
+
96
+ buildStart() {
97
+ this.pending = true;
98
+ }
99
+
100
+ buildSuccess(
101
+ bundleGraph: BundleGraph<PackagedBundle>,
102
+ requestBundle: (bundle: PackagedBundle) => Promise<BuildSuccessEvent>,
103
+ ) {
104
+ this.bundleGraph = bundleGraph;
105
+ this.requestBundle = requestBundle;
106
+ this.errors = null;
107
+ this.pending = false;
108
+
109
+ if (this.pendingRequests.length > 0) {
110
+ let pendingRequests = this.pendingRequests;
111
+ this.pendingRequests = [];
112
+ for (let [req, res] of pendingRequests) {
113
+ this.respond(req, res);
114
+ }
115
+ }
116
+ }
117
+
118
+ async buildError(options: PluginOptions, diagnostics: Array<Diagnostic>) {
119
+ this.pending = false;
120
+ this.errors = await Promise.all(
121
+ diagnostics.map(async d => {
122
+ let ansiDiagnostic = await prettyDiagnostic(d, options);
123
+
124
+ return {
125
+ message: ansiHtml(ansiDiagnostic.message),
126
+ stack: ansiDiagnostic.stack ? ansiHtml(ansiDiagnostic.stack) : null,
127
+ frames: ansiDiagnostic.frames.map(f => ({
128
+ location: f.location,
129
+ code: ansiHtml(f.code),
130
+ })),
131
+ hints: ansiDiagnostic.hints.map(hint => ansiHtml(hint)),
132
+ documentation: d.documentationURL ?? '',
133
+ };
134
+ }),
135
+ );
136
+ }
137
+
138
+ respond(req: Request, res: Response): mixed {
139
+ if (this.middleware.some(handler => handler(req, res))) return;
140
+ let {pathname, search} = url.parse(req.originalUrl || req.url);
141
+ if (pathname == null) {
142
+ pathname = '/';
143
+ }
144
+
145
+ if (pathname.startsWith(EDITOR_ENDPOINT) && search) {
146
+ let query = new URLSearchParams(search);
147
+ let file = query.get('file');
148
+ if (file) {
149
+ // File location might start with /__atlaspack_source_root if it came from a source map.
150
+ if (file.startsWith(SOURCES_ENDPOINT)) {
151
+ file = file.slice(SOURCES_ENDPOINT.length + 1);
152
+ }
153
+ launchEditor(file);
154
+ }
155
+ res.end();
156
+ } else if (this.errors) {
157
+ return this.send500(req, res);
158
+ } else if (path.extname(pathname) === '') {
159
+ // If the URL doesn't start with the public path, or the URL doesn't
160
+ // have a file extension, send the main HTML bundle.
161
+ return this.sendIndex(req, res);
162
+ } else if (pathname.startsWith(SOURCES_ENDPOINT)) {
163
+ req.url = pathname.slice(SOURCES_ENDPOINT.length);
164
+ return this.serve(
165
+ this.options.inputFS,
166
+ this.options.projectRoot,
167
+ req,
168
+ res,
169
+ () => this.send404(req, res),
170
+ );
171
+ } else if (pathname.startsWith(this.rootPath)) {
172
+ // Otherwise, serve the file from the dist folder
173
+ req.url =
174
+ this.rootPath === '/' ? pathname : pathname.slice(this.rootPath.length);
175
+ if (req.url[0] !== '/') {
176
+ req.url = '/' + req.url;
177
+ }
178
+ return this.serveBundle(req, res, () => this.sendIndex(req, res));
179
+ } else {
180
+ return this.send404(req, res);
181
+ }
182
+ }
183
+
184
+ sendIndex(req: Request, res: Response) {
185
+ if (this.bundleGraph) {
186
+ // If the main asset is an HTML file, serve it
187
+ let htmlBundleFilePaths = this.bundleGraph
188
+ .getBundles()
189
+ .filter(bundle => path.posix.extname(bundle.name) === '.html')
190
+ .map(bundle => {
191
+ return `/${relativePath(
192
+ this.options.distDir,
193
+ bundle.filePath,
194
+ false,
195
+ )}`;
196
+ });
197
+
198
+ let indexFilePath = null;
199
+ let {pathname: reqURL} = url.parse(req.originalUrl || req.url);
200
+
201
+ if (!reqURL) {
202
+ reqURL = '/';
203
+ }
204
+
205
+ if (htmlBundleFilePaths.length === 1) {
206
+ indexFilePath = htmlBundleFilePaths[0];
207
+ } else {
208
+ let bestMatch = null;
209
+ for (let bundle of htmlBundleFilePaths) {
210
+ let bundleDir = path.posix.dirname(bundle);
211
+ let bundleDirSubdir = bundleDir === '/' ? bundleDir : bundleDir + '/';
212
+ let withoutExtension = path.posix.basename(
213
+ bundle,
214
+ path.posix.extname(bundle),
215
+ );
216
+ let isIndex = withoutExtension === 'index';
217
+
218
+ let matchesIsIndex = null;
219
+ if (
220
+ isIndex &&
221
+ (reqURL.startsWith(bundleDirSubdir) || reqURL === bundleDir)
222
+ ) {
223
+ // bundle is /bar/index.html and (/bar or something inside of /bar/** was requested was requested)
224
+ matchesIsIndex = true;
225
+ } else if (reqURL == path.posix.join(bundleDir, withoutExtension)) {
226
+ // bundle is /bar/foo.html and /bar/foo was requested
227
+ matchesIsIndex = false;
228
+ }
229
+ if (matchesIsIndex != null) {
230
+ let depth = bundle.match(SLASH_REGEX)?.length ?? 0;
231
+ if (
232
+ bestMatch == null ||
233
+ // This one is more specific (deeper)
234
+ bestMatch.depth < depth ||
235
+ // This one is just as deep, but the bundle name matches and not just index.html
236
+ (bestMatch.depth === depth && bestMatch.isIndex)
237
+ ) {
238
+ bestMatch = {bundle, depth, isIndex: matchesIsIndex};
239
+ }
240
+ }
241
+ }
242
+ indexFilePath = bestMatch?.['bundle'] ?? htmlBundleFilePaths[0];
243
+ }
244
+
245
+ if (indexFilePath) {
246
+ req.url = indexFilePath;
247
+ this.serveBundle(req, res, () => this.send404(req, res));
248
+ } else {
249
+ this.send404(req, res);
250
+ }
251
+ } else {
252
+ this.send404(req, res);
253
+ }
254
+ }
255
+
256
+ async serveBundle(
257
+ req: Request,
258
+ res: Response,
259
+ next: NextFunction,
260
+ ): Promise<void> {
261
+ let bundleGraph = this.bundleGraph;
262
+ if (bundleGraph) {
263
+ let {pathname} = url.parse(req.url);
264
+ if (!pathname) {
265
+ this.send500(req, res);
266
+ return;
267
+ }
268
+
269
+ let requestedPath = path.normalize(pathname.slice(1));
270
+ let bundle = bundleGraph
271
+ .getBundles()
272
+ .find(
273
+ b =>
274
+ path.relative(this.options.distDir, b.filePath) === requestedPath,
275
+ );
276
+ if (!bundle) {
277
+ this.serveDist(req, res, next);
278
+ return;
279
+ }
280
+
281
+ invariant(this.requestBundle != null);
282
+ try {
283
+ await this.requestBundle(bundle);
284
+ } catch (err) {
285
+ this.send500(req, res);
286
+ return;
287
+ }
288
+
289
+ this.serveDist(req, res, next);
290
+ } else {
291
+ this.send404(req, res);
292
+ }
293
+ }
294
+
295
+ serveDist(
296
+ req: Request,
297
+ res: Response,
298
+ next: NextFunction,
299
+ ): Promise<void> | Promise<mixed> {
300
+ return this.serve(
301
+ this.options.outputFS,
302
+ this.options.distDir,
303
+ req,
304
+ res,
305
+ next,
306
+ );
307
+ }
308
+
309
+ async serve(
310
+ fs: FileSystem,
311
+ root: FilePath,
312
+ req: Request,
313
+ res: Response,
314
+ next: NextFunction,
315
+ ): Promise<mixed> {
316
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
317
+ // method not allowed
318
+ res.statusCode = 405;
319
+ res.setHeader('Allow', 'GET, HEAD');
320
+ res.setHeader('Content-Length', '0');
321
+ res.end();
322
+ return;
323
+ }
324
+
325
+ try {
326
+ var filePath = url.parse(req.url).pathname || '';
327
+ filePath = decodeURIComponent(filePath);
328
+ } catch (err) {
329
+ return this.sendError(res, 400);
330
+ }
331
+
332
+ filePath = path.normalize('.' + path.sep + filePath);
333
+
334
+ // malicious path
335
+ if (filePath.includes(path.sep + '..' + path.sep)) {
336
+ return this.sendError(res, 403);
337
+ }
338
+
339
+ // join / normalize from the root dir
340
+ if (!path.isAbsolute(filePath)) {
341
+ filePath = path.normalize(path.join(root, filePath));
342
+ }
343
+
344
+ try {
345
+ var stat = await fs.stat(filePath);
346
+ } catch (err) {
347
+ if (err.code === 'ENOENT') {
348
+ return next(req, res);
349
+ }
350
+
351
+ return this.sendError(res, 500);
352
+ }
353
+
354
+ // Fall back to next handler if not a file
355
+ if (!stat || !stat.isFile()) {
356
+ return next(req, res);
357
+ }
358
+
359
+ if (fresh(req.headers, {'last-modified': stat.mtime.toUTCString()})) {
360
+ res.statusCode = 304;
361
+ res.end();
362
+ return;
363
+ }
364
+
365
+ return serveHandler(
366
+ req,
367
+ res,
368
+ {
369
+ public: root,
370
+ cleanUrls: false,
371
+ },
372
+ {
373
+ lstat: path => fs.stat(path),
374
+ realpath: path => fs.realpath(path),
375
+ createReadStream: (path, options) => fs.createReadStream(path, options),
376
+ readdir: path => fs.readdir(path),
377
+ },
378
+ );
379
+ }
380
+
381
+ sendError(res: Response, statusCode: number) {
382
+ res.statusCode = statusCode;
383
+ res.end();
384
+ }
385
+
386
+ send404(req: Request, res: Response) {
387
+ res.statusCode = 404;
388
+ res.end(TEMPLATE_404);
389
+ }
390
+
391
+ send500(req: Request, res: Response): void | Response {
392
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
393
+ res.writeHead(500);
394
+
395
+ if (this.errors) {
396
+ return res.end(
397
+ ejs.render(TEMPLATE_500, {
398
+ errors: this.errors,
399
+ hmrOptions: this.options.hmrOptions,
400
+ }),
401
+ );
402
+ }
403
+ }
404
+
405
+ logAccessIfVerbose(req: Request) {
406
+ this.options.logger.verbose({
407
+ message: `Request: ${req.headers.host}${req.originalUrl || req.url}`,
408
+ });
409
+ }
410
+
411
+ /**
412
+ * Load proxy table from package.json and apply them.
413
+ */
414
+ async applyProxyTable(app: any): Promise<Server> {
415
+ // avoid skipping project root
416
+ const fileInRoot: string = path.join(this.options.projectRoot, 'index');
417
+
418
+ const configFilePath = await resolveConfig(
419
+ this.options.inputFS,
420
+ fileInRoot,
421
+ [
422
+ '.proxyrc.cts',
423
+ '.proxyrc.mts',
424
+ '.proxyrc.ts',
425
+ '.proxyrc.cjs',
426
+ '.proxyrc.mjs',
427
+ '.proxyrc.js',
428
+ '.proxyrc',
429
+ '.proxyrc.json',
430
+ ],
431
+ this.options.projectRoot,
432
+ );
433
+
434
+ if (!configFilePath) {
435
+ return this;
436
+ }
437
+
438
+ const filename = path.basename(configFilePath);
439
+
440
+ if (filename === '.proxyrc' || filename === '.proxyrc.json') {
441
+ let conf = await readConfig(this.options.inputFS, configFilePath);
442
+ if (!conf) {
443
+ return this;
444
+ }
445
+ let cfg = conf.config;
446
+ if (typeof cfg !== 'object') {
447
+ this.options.logger.warn({
448
+ message:
449
+ "Proxy table in '.proxyrc' should be of object type. Skipping...",
450
+ });
451
+ return this;
452
+ }
453
+ for (const [context, options] of Object.entries(cfg)) {
454
+ // each key is interpreted as context, and value as middleware options
455
+ app.use(createProxyMiddleware(context, options));
456
+ }
457
+ } else {
458
+ let cfg = await this.options.packageManager.require(
459
+ configFilePath,
460
+ fileInRoot,
461
+ );
462
+ if (
463
+ // $FlowFixMe
464
+ Object.prototype.toString.call(cfg) === '[object Module]'
465
+ ) {
466
+ cfg = cfg.default;
467
+ }
468
+
469
+ if (typeof cfg !== 'function') {
470
+ this.options.logger.warn({
471
+ message: `Proxy configuration file '${filename}' should export a function. Skipping...`,
472
+ });
473
+ return this;
474
+ }
475
+ cfg(app);
476
+ }
477
+
478
+ return this;
479
+ }
480
+
481
+ async start(): Promise<HTTPServer> {
482
+ const finalHandler = (req: Request, res: Response) => {
483
+ this.logAccessIfVerbose(req);
484
+
485
+ // Wait for the atlaspackInstance to finish bundling if needed
486
+ if (this.pending) {
487
+ this.pendingRequests.push([req, res]);
488
+ } else {
489
+ this.respond(req, res);
490
+ }
491
+ };
492
+
493
+ const app = connect();
494
+ app.use((req, res, next) => {
495
+ setHeaders(res);
496
+ next();
497
+ });
498
+
499
+ app.use((req, res, next) => {
500
+ if (req.url === '/__atlaspack_healthcheck') {
501
+ res.statusCode = 200;
502
+ res.write(`${Date.now()}`);
503
+ res.end();
504
+ } else {
505
+ next();
506
+ }
507
+ });
508
+
509
+ await this.applyProxyTable(app);
510
+ app.use(finalHandler);
511
+
512
+ let {server, stop} = await createHTTPServer({
513
+ cacheDir: this.options.cacheDir,
514
+ https: this.options.https,
515
+ inputFS: this.options.inputFS,
516
+ listener: app,
517
+ outputFS: this.options.outputFS,
518
+ host: this.options.host,
519
+ });
520
+ this.stopServer = stop;
521
+
522
+ server.listen(this.options.port, this.options.host);
523
+ return new Promise((resolve, reject) => {
524
+ server.once('error', err => {
525
+ this.options.logger.error(
526
+ ({
527
+ message: serverErrors(err, this.options.port),
528
+ }: Diagnostic),
529
+ );
530
+ reject(err);
531
+ });
532
+
533
+ server.once('listening', () => {
534
+ resolve(server);
535
+ });
536
+ });
537
+ }
538
+
539
+ async stop(): Promise<void> {
540
+ invariant(this.stopServer != null);
541
+ await this.stopServer();
542
+ this.stopServer = null;
543
+ }
544
+ }
@@ -0,0 +1,143 @@
1
+ // @flow
2
+
3
+ import {Reporter} from '@atlaspack/plugin';
4
+ import HMRServer from './HMRServer';
5
+ import Server from './Server';
6
+
7
+ let servers: Map<number, Server> = new Map();
8
+ let hmrServers: Map<number, HMRServer> = new Map();
9
+ export default (new Reporter({
10
+ async report({event, options, logger}) {
11
+ let {serveOptions, hmrOptions} = options;
12
+ let server = serveOptions ? servers.get(serveOptions.port) : undefined;
13
+ let hmrPort =
14
+ (hmrOptions && hmrOptions.port) || (serveOptions && serveOptions.port);
15
+ let hmrServer = hmrPort ? hmrServers.get(hmrPort) : undefined;
16
+ switch (event.type) {
17
+ case 'watchStart': {
18
+ if (serveOptions) {
19
+ // If there's already a server when watching has just started, something
20
+ // is wrong.
21
+ if (server) {
22
+ return logger.warn({
23
+ message: 'Trying to create the devserver but it already exists.',
24
+ });
25
+ }
26
+
27
+ let serverOptions = {
28
+ ...serveOptions,
29
+ projectRoot: options.projectRoot,
30
+ cacheDir: options.cacheDir,
31
+ // Override the target's publicUrl as that is likely meant for production.
32
+ // This could be configurable in the future.
33
+ publicUrl: serveOptions.publicUrl ?? '/',
34
+ inputFS: options.inputFS,
35
+ outputFS: options.outputFS,
36
+ packageManager: options.packageManager,
37
+ logger,
38
+ hmrOptions,
39
+ };
40
+
41
+ server = new Server(serverOptions);
42
+ servers.set(serveOptions.port, server);
43
+ const devServer = await server.start();
44
+
45
+ if (hmrOptions && hmrOptions.port === serveOptions.port) {
46
+ let hmrServerOptions = {
47
+ port: serveOptions.port,
48
+ host: hmrOptions.host,
49
+ devServer,
50
+ addMiddleware: handler => {
51
+ server?.middleware.push(handler);
52
+ },
53
+ logger,
54
+ https: options.serveOptions ? options.serveOptions.https : false,
55
+ cacheDir: options.cacheDir,
56
+ inputFS: options.inputFS,
57
+ outputFS: options.outputFS,
58
+ };
59
+ hmrServer = new HMRServer(hmrServerOptions);
60
+ hmrServers.set(serveOptions.port, hmrServer);
61
+ await hmrServer.start();
62
+ return;
63
+ }
64
+ }
65
+
66
+ let port = hmrOptions?.port;
67
+ if (typeof port === 'number') {
68
+ let hmrServerOptions = {
69
+ port,
70
+ host: hmrOptions?.host,
71
+ logger,
72
+ https: options.serveOptions ? options.serveOptions.https : false,
73
+ cacheDir: options.cacheDir,
74
+ inputFS: options.inputFS,
75
+ outputFS: options.outputFS,
76
+ };
77
+ hmrServer = new HMRServer(hmrServerOptions);
78
+ hmrServers.set(port, hmrServer);
79
+ await hmrServer.start();
80
+ }
81
+ break;
82
+ }
83
+ case 'watchEnd':
84
+ if (serveOptions) {
85
+ if (!server) {
86
+ return logger.warn({
87
+ message:
88
+ 'Could not shutdown devserver because it does not exist.',
89
+ });
90
+ }
91
+ await server.stop();
92
+ servers.delete(server.options.port);
93
+ }
94
+ if (hmrOptions && hmrServer) {
95
+ await hmrServer.stop();
96
+ // $FlowFixMe[prop-missing]
97
+ hmrServers.delete(hmrServer.wss.options.port);
98
+ }
99
+ break;
100
+ case 'buildStart':
101
+ if (server) {
102
+ server.buildStart();
103
+ }
104
+ break;
105
+ case 'buildProgress':
106
+ if (
107
+ event.phase === 'bundled' &&
108
+ hmrServer &&
109
+ // Only send HMR updates before packaging if the built in dev server is used to ensure that
110
+ // no stale bundles are served. Otherwise emit it for 'buildSuccess'.
111
+ options.serveOptions !== false
112
+ ) {
113
+ await hmrServer.emitUpdate(event);
114
+ }
115
+ break;
116
+ case 'buildSuccess':
117
+ if (serveOptions) {
118
+ if (!server) {
119
+ return logger.warn({
120
+ message:
121
+ 'Could not send success event to devserver because it does not exist.',
122
+ });
123
+ }
124
+
125
+ server.buildSuccess(event.bundleGraph, event.requestBundle);
126
+ }
127
+ if (hmrServer && options.serveOptions === false) {
128
+ await hmrServer.emitUpdate(event);
129
+ }
130
+ break;
131
+ case 'buildFailure':
132
+ // On buildFailure watchStart sometimes has not been called yet
133
+ // do not throw an additional warning here
134
+ if (server) {
135
+ await server.buildError(options, event.diagnostics);
136
+ }
137
+ if (hmrServer) {
138
+ await hmrServer.emitError(options, event.diagnostics);
139
+ }
140
+ break;
141
+ }
142
+ },
143
+ }): Reporter);
@@ -0,0 +1,21 @@
1
+ // @flow
2
+ export type ServerError = Error & {|
3
+ code: string,
4
+ |};
5
+
6
+ const serverErrorList = {
7
+ EACCES: "You don't have access to bind the server to port {port}.",
8
+ EADDRINUSE: 'There is already a process listening on port {port}.',
9
+ };
10
+
11
+ export default function serverErrors(err: ServerError, port: number): string {
12
+ let desc = `Error: ${
13
+ err.code
14
+ } occurred while setting up server on port ${port.toString()}.`;
15
+
16
+ if (serverErrorList[err.code]) {
17
+ desc = serverErrorList[err.code].replace(/{port}/g, port);
18
+ }
19
+
20
+ return desc;
21
+ }