UNPKG

25.6 kBJavaScriptView Raw
1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3const cli_framework_1 = require("@ionic/cli-framework");
4const string_1 = require("@ionic/cli-framework/utils/string");
5const utils_fs_1 = require("@ionic/utils-fs");
6const utils_network_1 = require("@ionic/utils-network");
7const utils_process_1 = require("@ionic/utils-process");
8const chalk = require("chalk");
9const Debug = require("debug");
10const events_1 = require("events");
11const lodash = require("lodash");
12const os = require("os");
13const path = require("path");
14const split2 = require("split2");
15const through2 = require("through2");
16const constants_1 = require("../constants");
17const guards_1 = require("../guards");
18const color_1 = require("./color");
19const errors_1 = require("./errors");
20const events_2 = require("./events");
21const hooks_1 = require("./hooks");
22const open_1 = require("./open");
23const logger_1 = require("./utils/logger");
24const debug = Debug('ionic:lib:serve');
25exports.DEFAULT_DEV_LOGGER_PORT = 53703;
26exports.DEFAULT_LIVERELOAD_PORT = 35729;
27exports.DEFAULT_SERVER_PORT = 8100;
28exports.DEFAULT_LAB_PORT = 8200;
29exports.DEFAULT_DEVAPP_COMM_PORT = 53233;
30exports.DEFAULT_ADDRESS = 'localhost';
31exports.BIND_ALL_ADDRESS = '0.0.0.0';
32exports.LOCAL_ADDRESSES = ['localhost', '127.0.0.1'];
33exports.BROWSERS = ['safari', 'firefox', process.platform === 'win32' ? 'chrome' : (process.platform === 'darwin' ? 'google chrome' : 'google-chrome')];
34// npm script name
35exports.SERVE_SCRIPT = 'ionic:serve';
36exports.COMMON_SERVE_COMMAND_OPTIONS = [
37 {
38 name: 'external',
39 summary: `Host dev server on all network interfaces (i.e. ${color_1.input('--address=0.0.0.0')})`,
40 type: Boolean,
41 },
42 {
43 name: 'address',
44 summary: 'Use specific address for the dev server',
45 default: exports.DEFAULT_ADDRESS,
46 groups: ["advanced" /* ADVANCED */],
47 },
48 {
49 name: 'port',
50 summary: 'Use specific port for HTTP',
51 default: exports.DEFAULT_SERVER_PORT.toString(),
52 aliases: ['p'],
53 groups: ["advanced" /* ADVANCED */],
54 },
55 {
56 name: 'livereload',
57 summary: 'Do not spin up dev server--just serve files',
58 type: Boolean,
59 default: true,
60 },
61 {
62 name: 'engine',
63 summary: `Target engine (e.g. ${['browser', 'cordova'].map(e => color_1.input(e)).join(', ')})`,
64 groups: ["hidden" /* HIDDEN */, "advanced" /* ADVANCED */],
65 },
66 {
67 name: 'platform',
68 summary: `Target platform on chosen engine (e.g. ${['ios', 'android'].map(e => color_1.input(e)).join(', ')})`,
69 groups: ["hidden" /* HIDDEN */, "advanced" /* ADVANCED */],
70 },
71 {
72 name: 'devapp',
73 summary: 'Publish DevApp service',
74 type: Boolean,
75 default: false,
76 groups: ["advanced" /* ADVANCED */],
77 },
78];
79class ServeRunner {
80 constructor() {
81 this.devAppConnectionMade = false;
82 }
83 getPkgManagerServeCLI() {
84 return this.e.config.get('npmClient') === 'npm' ? new NpmServeCLI(this.e) : new YarnServeCLI(this.e);
85 }
86 createOptionsFromCommandLine(inputs, options) {
87 const separatedArgs = options['--'];
88 if (options['external'] || (options['devapp'] && options['address'] === exports.DEFAULT_ADDRESS)) {
89 options['address'] = '0.0.0.0';
90 }
91 const engine = this.determineEngineFromCommandLine(options);
92 const address = options['address'] ? String(options['address']) : exports.DEFAULT_ADDRESS;
93 const labPort = string_1.str2num(options['lab-port'], exports.DEFAULT_LAB_PORT);
94 const port = string_1.str2num(options['port'], exports.DEFAULT_SERVER_PORT);
95 return {
96 '--': separatedArgs ? separatedArgs : [],
97 address,
98 browser: options['browser'] ? String(options['browser']) : undefined,
99 browserOption: options['browseroption'] ? String(options['browseroption']) : undefined,
100 devapp: !!options['devapp'],
101 engine,
102 externalAddressRequired: !!options['externalAddressRequired'],
103 lab: !!options['lab'],
104 labHost: options['lab-host'] ? String(options['lab-host']) : 'localhost',
105 labPort,
106 livereload: typeof options['livereload'] === 'boolean' ? Boolean(options['livereload']) : true,
107 open: !!options['open'],
108 platform: options['platform'] ? String(options['platform']) : undefined,
109 port,
110 proxy: typeof options['proxy'] === 'boolean' ? Boolean(options['proxy']) : true,
111 project: options['project'] ? String(options['project']) : undefined,
112 verbose: !!options['verbose'],
113 };
114 }
115 determineEngineFromCommandLine(options) {
116 if (options['engine']) {
117 return String(options['engine']);
118 }
119 if (options['cordova']) {
120 return 'cordova';
121 }
122 return 'browser';
123 }
124 async displayDevAppMessage(options) {
125 const pkg = await this.e.project.requirePackageJson();
126 // If this is regular `ionic serve`, we warn the dev about unsupported
127 // plugins in the devapp.
128 if (options.devapp && guards_1.isCordovaPackageJson(pkg)) {
129 const plugins = await this.getSupportedDevAppPlugins();
130 const packageCordovaPlugins = Object.keys(pkg.cordova.plugins);
131 const packageCordovaPluginsDiff = packageCordovaPlugins.filter(p => !plugins.has(p));
132 if (packageCordovaPluginsDiff.length > 0) {
133 this.e.log.warn('Detected unsupported Cordova plugins with Ionic DevApp:\n' +
134 `${packageCordovaPluginsDiff.map(p => `- ${color_1.strong(p)}`).join('\n')}\n\n` +
135 `App may not function as expected in Ionic DevApp.`);
136 this.e.log.nl();
137 }
138 }
139 }
140 async beforeServe(options) {
141 const hook = new ServeBeforeHook(this.e);
142 try {
143 await hook.run({ name: hook.name, serve: options });
144 }
145 catch (e) {
146 if (e instanceof cli_framework_1.BaseError) {
147 throw new errors_1.FatalException(e.message);
148 }
149 throw e;
150 }
151 }
152 async run(options) {
153 debug('serve options: %O', options);
154 await this.beforeServe(options);
155 if (options.devapp) {
156 this.e.log.warn(`The DevApp has been retired.\n` +
157 `The app will no longer receive updates and the ${color_1.input('--devapp')} flag will be removed in Ionic CLI 6. See the DevApp docs${color_1.ancillary('[1]')} for details.\n\n` +
158 `${color_1.ancillary('[1]')}: ${color_1.strong('https://ionicframework.com/docs/appflow/devapp')}\n`);
159 }
160 const details = await this.serveProject(options);
161 const devAppDetails = await this.gatherDevAppDetails(options, details);
162 const labDetails = options.lab ? await this.runLab(options, details) : undefined;
163 if (devAppDetails) {
164 const devAppName = await this.publishDevApp(options, devAppDetails);
165 devAppDetails.channel = devAppName;
166 }
167 const localAddress = `${details.protocol}://localhost:${details.port}`;
168 const fmtExternalAddress = (address) => `${details.protocol}://${address}:${details.port}`;
169 const labAddress = labDetails ? `http://${labDetails.address}:${labDetails.port}` : undefined;
170 this.e.log.nl();
171 this.e.log.info(`Development server running!` +
172 (labAddress ? `\nLab: ${color_1.strong(labAddress)}` : '') +
173 `\nLocal: ${color_1.strong(localAddress)}` +
174 (details.externalNetworkInterfaces.length > 0 ? `\nExternal: ${details.externalNetworkInterfaces.map(v => color_1.strong(fmtExternalAddress(v.address))).join(', ')}` : '') +
175 (devAppDetails && devAppDetails.channel ? `\nDevApp: ${color_1.strong(devAppDetails.channel)} on ${color_1.strong(os.hostname())}` : '') +
176 `\n\n${chalk.yellow('Use Ctrl+C to quit this process')}`);
177 this.e.log.nl();
178 if (options.open) {
179 const openAddress = labAddress ? labAddress : localAddress;
180 const url = this.modifyOpenUrl(openAddress, options);
181 await open_1.openUrl(url, { app: options.browser });
182 this.e.log.info(`Browser window opened to ${color_1.strong(url)}!`);
183 this.e.log.nl();
184 }
185 events_2.emit('serve:ready', details);
186 debug('serve details: %O', details);
187 this.scheduleAfterServe(options, details);
188 return details;
189 }
190 async afterServe(options, details) {
191 const hook = new ServeAfterHook(this.e);
192 try {
193 await hook.run({ name: hook.name, serve: lodash.assign({}, options, details) });
194 }
195 catch (e) {
196 if (e instanceof cli_framework_1.BaseError) {
197 throw new errors_1.FatalException(e.message);
198 }
199 throw e;
200 }
201 }
202 scheduleAfterServe(options, details) {
203 utils_process_1.onBeforeExit(async () => this.afterServe(options, details));
204 }
205 getUsedPorts(options, details) {
206 return [details.port];
207 }
208 async gatherDevAppDetails(options, details) {
209 if (options.devapp) {
210 const { computeBroadcastAddress } = await Promise.resolve().then(() => require('./devapp'));
211 // TODO: There is no accurate/reliable/realistic way to identify a WiFi
212 // network uniquely in NodeJS. But this is where we could detect new
213 // networks and prompt the dev if they want to "trust" it (allow binding to
214 // 0.0.0.0 and broadcasting).
215 const interfaces = utils_network_1.getExternalIPv4Interfaces()
216 .map(i => ({ ...i, broadcast: computeBroadcastAddress(i.address, i.netmask) }));
217 const { port } = details;
218 // the comm server always binds to 0.0.0.0 to target every possible interface
219 const commPort = await utils_network_1.findClosestOpenPort(exports.DEFAULT_DEVAPP_COMM_PORT);
220 return { port, commPort, interfaces };
221 }
222 }
223 async publishDevApp(options, details) {
224 if (options.devapp) {
225 const { createCommServer, createPublisher } = await Promise.resolve().then(() => require('./devapp'));
226 const publisher = await createPublisher(this.e.project.config.get('name'), details.port, details.commPort);
227 const comm = await createCommServer(publisher.id, details.commPort);
228 publisher.interfaces = details.interfaces;
229 comm.on('error', (err) => {
230 debug(`Error in DevApp service: ${String(err.stack ? err.stack : err)}`);
231 });
232 comm.on('connect', async (data) => {
233 this.e.log.info(`DevApp connection established from ${color_1.strong(data.device)}`);
234 this.e.log.nl();
235 if (!this.devAppConnectionMade) {
236 this.devAppConnectionMade = true;
237 await this.displayDevAppMessage(options);
238 }
239 });
240 publisher.on('error', (err) => {
241 debug(`Error in DevApp service: ${String(err.stack ? err.stack : err)}`);
242 });
243 try {
244 await comm.start();
245 }
246 catch (e) {
247 this.e.log.error(`Could not create DevApp comm server: ${String(e.stack ? e.stack : e)}`);
248 }
249 try {
250 await publisher.start();
251 }
252 catch (e) {
253 this.e.log.error(`Could not publish DevApp service: ${String(e.stack ? e.stack : e)}`);
254 }
255 return publisher.name;
256 }
257 }
258 async getSupportedDevAppPlugins() {
259 const p = path.resolve(constants_1.ASSETS_DIRECTORY, 'devapp', 'plugins.json');
260 const plugins = await utils_fs_1.readJson(p);
261 if (!Array.isArray(plugins)) {
262 throw new Error(`Cannot read ${p} file of supported plugins.`);
263 }
264 // This one is common, and hopefully obvious enough that the devapp doesn't
265 // use any splash screen but its own, so we mark it as "supported".
266 plugins.push('cordova-plugin-splashscreen');
267 return new Set(plugins);
268 }
269 async runLab(options, serveDetails) {
270 const labDetails = {
271 projectType: this.e.project.type,
272 address: options.labHost,
273 port: await utils_network_1.findClosestOpenPort(options.labPort),
274 };
275 const lab = new IonicLabServeCLI(this.e);
276 await lab.serve({ serveDetails, ...labDetails });
277 return labDetails;
278 }
279 async selectExternalIP(options) {
280 let availableInterfaces = [];
281 let chosenIP = options.address;
282 if (options.address === exports.BIND_ALL_ADDRESS) {
283 // ignore link-local addresses
284 availableInterfaces = utils_network_1.getExternalIPv4Interfaces().filter(i => !i.address.startsWith('169.254'));
285 if (availableInterfaces.length === 0) {
286 if (options.externalAddressRequired) {
287 throw new errors_1.FatalException(`No external network interfaces detected. In order to use the dev server externally you will need one.\n` +
288 `Are you connected to a local network?\n`);
289 }
290 }
291 else if (availableInterfaces.length === 1) {
292 chosenIP = availableInterfaces[0].address;
293 }
294 else if (availableInterfaces.length > 1) {
295 if (options.externalAddressRequired) {
296 if (this.e.flags.interactive) {
297 this.e.log.warn('Multiple network interfaces detected!\n' +
298 'You will be prompted to select an external-facing IP for the dev server that your device or emulator has access to.\n\n' +
299 `You may also use the ${color_1.input('--address')} option to skip this prompt.`);
300 const promptedIp = await this.e.prompt({
301 type: 'list',
302 name: 'promptedIp',
303 message: 'Please select which IP to use:',
304 choices: availableInterfaces.map(i => ({
305 name: `${i.address} ${color_1.weak(`(${i.device})`)}`,
306 value: i.address,
307 })),
308 });
309 chosenIP = promptedIp;
310 }
311 else {
312 throw new errors_1.FatalException(`Multiple network interfaces detected!\n` +
313 `You must select an external-facing IP for the dev server that your device or emulator has access to with the ${color_1.input('--address')} option.`);
314 }
315 }
316 }
317 }
318 else if (options.externalAddressRequired && exports.LOCAL_ADDRESSES.includes(options.address)) {
319 this.e.log.warn('An external host may be required to serve for this target device/platform.\n' +
320 'If you get connection issues on your device or emulator, try connecting the device to the same Wi-Fi network and selecting an accessible IP address for your computer on that network.\n\n' +
321 `You can use ${color_1.input('--external')} to run the dev server on all network interfaces, in which case an external address will be selected.\n`);
322 }
323 return [chosenIP, availableInterfaces];
324 }
325}
326exports.ServeRunner = ServeRunner;
327class ServeBeforeHook extends hooks_1.Hook {
328 constructor() {
329 super(...arguments);
330 this.name = 'serve:before';
331 }
332}
333class ServeAfterHook extends hooks_1.Hook {
334 constructor() {
335 super(...arguments);
336 this.name = 'serve:after';
337 }
338}
339class ServeCLI extends events_1.EventEmitter {
340 constructor(e) {
341 super();
342 this.e = e;
343 /**
344 * If true, the Serve CLI will not prompt to be installed.
345 */
346 this.global = false;
347 }
348 get resolvedProgram() {
349 if (this._resolvedProgram) {
350 return this._resolvedProgram;
351 }
352 return this.program;
353 }
354 /**
355 * Build the environment variables to be passed to the Serve CLI. Called by `this.start()`;
356 */
357 async buildEnvVars(options) {
358 return process.env;
359 }
360 /**
361 * Called whenever a line of stdout is received.
362 *
363 * If `false` is returned, the line is not emitted to the log.
364 *
365 * By default, the CLI is considered ready whenever stdout is emitted. This
366 * method should be overridden to more accurately portray readiness.
367 *
368 * @param line A line of stdout.
369 */
370 stdoutFilter(line) {
371 this.emit('ready');
372 return true;
373 }
374 /**
375 * Called whenever a line of stderr is received.
376 *
377 * If `false` is returned, the line is not emitted to the log.
378 */
379 stderrFilter(line) {
380 return true;
381 }
382 async resolveScript() {
383 if (typeof this.script === 'undefined') {
384 return;
385 }
386 const pkg = await this.e.project.requirePackageJson();
387 return pkg.scripts && pkg.scripts[this.script];
388 }
389 async serve(options) {
390 this._resolvedProgram = await this.resolveProgram();
391 await this.spawnWrapper(options);
392 const interval = setInterval(() => {
393 this.e.log.info(`Waiting for connectivity with ${color_1.input(this.resolvedProgram)}...`);
394 }, 5000);
395 debug('awaiting TCP connection to %s:%d', options.address, options.port);
396 await utils_network_1.isHostConnectable(options.address, options.port);
397 clearInterval(interval);
398 }
399 async spawnWrapper(options) {
400 try {
401 return await this.spawn(options);
402 }
403 catch (e) {
404 if (!(e instanceof errors_1.ServeCLIProgramNotFoundException)) {
405 throw e;
406 }
407 if (this.global) {
408 this.e.log.nl();
409 throw new errors_1.FatalException(`${color_1.input(this.pkg)} is required for this command to work properly.`);
410 }
411 this.e.log.nl();
412 this.e.log.info(`Looks like ${color_1.input(this.pkg)} isn't installed in this project.\n` +
413 `This package is required for this command to work properly. The package provides a CLI utility, but the ${color_1.input(this.resolvedProgram)} binary was not found in your PATH.`);
414 const installed = await this.promptToInstall();
415 if (!installed) {
416 this.e.log.nl();
417 throw new errors_1.FatalException(`${color_1.input(this.pkg)} is required for this command to work properly.`);
418 }
419 return this.spawn(options);
420 }
421 }
422 async spawn(options) {
423 const args = await this.buildArgs(options);
424 const env = await this.buildEnvVars(options);
425 const p = await this.e.shell.spawn(this.resolvedProgram, args, { stdio: 'pipe', cwd: this.e.project.directory, env: utils_process_1.createProcessEnv(env) });
426 return new Promise((resolve, reject) => {
427 const errorHandler = (err) => {
428 debug('received error for %s: %o', this.resolvedProgram, err);
429 if (this.resolvedProgram === this.program && err.code === 'ENOENT') {
430 p.removeListener('close', closeHandler); // do not exit Ionic CLI, we can gracefully ask to install this CLI
431 reject(new errors_1.ServeCLIProgramNotFoundException(`${color_1.strong(this.resolvedProgram)} command not found.`));
432 }
433 else {
434 reject(err);
435 }
436 };
437 const closeHandler = (code) => {
438 if (code !== null) { // tslint:disable-line:no-null-keyword
439 debug('received unexpected close for %s (code: %d)', this.resolvedProgram, code);
440 this.e.log.nl();
441 this.e.log.error(`${color_1.input(this.resolvedProgram)} has unexpectedly closed (exit code ${code}).\n` +
442 'The Ionic CLI will exit. Please check any output above for error details.');
443 utils_process_1.processExit(1); // tslint:disable-line:no-floating-promises
444 }
445 };
446 p.on('error', errorHandler);
447 p.on('close', closeHandler);
448 utils_process_1.onBeforeExit(async () => {
449 p.removeListener('close', closeHandler);
450 if (p.pid) {
451 await utils_process_1.killProcessTree(p.pid);
452 }
453 });
454 const ws = this.createLoggerStream();
455 p.stdout.pipe(split2()).pipe(this.createStreamFilter(line => this.stdoutFilter(line))).pipe(ws);
456 p.stderr.pipe(split2()).pipe(this.createStreamFilter(line => this.stderrFilter(line))).pipe(ws);
457 this.once('ready', () => {
458 resolve();
459 });
460 });
461 }
462 createLoggerStream() {
463 const log = this.e.log.clone();
464 log.handlers = logger_1.createDefaultLoggerHandlers(cli_framework_1.createPrefixedFormatter(color_1.weak(`[${this.resolvedProgram === this.program ? this.prefix : this.resolvedProgram}]`)));
465 return log.createWriteStream(cli_framework_1.LOGGER_LEVELS.INFO);
466 }
467 async resolveProgram() {
468 if (typeof this.script !== 'undefined') {
469 debug(`Looking for ${color_1.ancillary(this.script)} npm script.`);
470 if (await this.resolveScript()) {
471 debug(`Using ${color_1.ancillary(this.script)} npm script.`);
472 return this.e.config.get('npmClient');
473 }
474 }
475 return this.program;
476 }
477 createStreamFilter(filter) {
478 return through2(function (chunk, enc, callback) {
479 const str = chunk.toString();
480 if (filter(str)) {
481 this.push(chunk);
482 }
483 callback();
484 });
485 }
486 async promptToInstall() {
487 const { pkgManagerArgs } = await Promise.resolve().then(() => require('./utils/npm'));
488 const [manager, ...managerArgs] = await pkgManagerArgs(this.e.config.get('npmClient'), { command: 'install', pkg: this.pkg, saveDev: true, saveExact: true });
489 this.e.log.nl();
490 const confirm = await this.e.prompt({
491 name: 'confirm',
492 message: `Install ${color_1.input(this.pkg)}?`,
493 type: 'confirm',
494 });
495 if (!confirm) {
496 this.e.log.warn(`Not installing--here's how to install manually: ${color_1.input(`${manager} ${managerArgs.join(' ')}`)}`);
497 return false;
498 }
499 await this.e.shell.run(manager, managerArgs, { cwd: this.e.project.directory });
500 return true;
501 }
502}
503exports.ServeCLI = ServeCLI;
504class PkgManagerServeCLI extends ServeCLI {
505 constructor() {
506 super(...arguments);
507 this.global = true;
508 this.script = exports.SERVE_SCRIPT;
509 }
510 async resolveProgram() {
511 return this.program;
512 }
513 async buildArgs(options) {
514 const { pkgManagerArgs } = await Promise.resolve().then(() => require('./utils/npm'));
515 // The Ionic CLI decides the host/port of the dev server, so --host and
516 // --port are provided to the downstream npm script as a best-effort
517 // attempt.
518 const args = {
519 _: [],
520 host: options.address,
521 port: options.port.toString(),
522 };
523 const scriptArgs = [...cli_framework_1.unparseArgs(args), ...options['--'] || []];
524 const [, ...pkgArgs] = await pkgManagerArgs(this.program, { command: 'run', script: this.script, scriptArgs });
525 return pkgArgs;
526 }
527}
528class NpmServeCLI extends PkgManagerServeCLI {
529 constructor() {
530 super(...arguments);
531 this.name = 'npm CLI';
532 this.pkg = 'npm';
533 this.program = 'npm';
534 this.prefix = 'npm';
535 }
536}
537exports.NpmServeCLI = NpmServeCLI;
538class YarnServeCLI extends PkgManagerServeCLI {
539 constructor() {
540 super(...arguments);
541 this.name = 'Yarn';
542 this.pkg = 'yarn';
543 this.program = 'yarn';
544 this.prefix = 'yarn';
545 }
546}
547exports.YarnServeCLI = YarnServeCLI;
548class IonicLabServeCLI extends ServeCLI {
549 constructor() {
550 super(...arguments);
551 this.name = 'Ionic Lab';
552 this.pkg = '@ionic/lab';
553 this.program = 'ionic-lab';
554 this.prefix = 'lab';
555 this.script = undefined;
556 }
557 stdoutFilter(line) {
558 if (line.includes('running')) {
559 this.emit('ready');
560 }
561 return false; // no stdout
562 }
563 async buildArgs(options) {
564 const { serveDetails, ...labDetails } = options;
565 const pkg = await this.e.project.requirePackageJson();
566 const url = `${serveDetails.protocol}://localhost:${serveDetails.port}`;
567 const appName = this.e.project.config.get('name');
568 const labArgs = [url, '--host', labDetails.address, '--port', String(labDetails.port), '--project-type', labDetails.projectType];
569 const nameArgs = appName ? ['--app-name', appName] : [];
570 const versionArgs = pkg.version ? ['--app-version', pkg.version] : [];
571 return [...labArgs, ...nameArgs, ...versionArgs];
572 }
573}