UNPKG

25.2 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_1 = 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 const details = await this.serveProject(options);
156 const devAppDetails = await this.gatherDevAppDetails(options, details);
157 const labDetails = options.lab ? await this.runLab(options, details) : undefined;
158 if (devAppDetails) {
159 const devAppName = await this.publishDevApp(options, devAppDetails);
160 devAppDetails.channel = devAppName;
161 }
162 const localAddress = `${details.protocol}://localhost:${details.port}`;
163 const fmtExternalAddress = (address) => `${details.protocol}://${address}:${details.port}`;
164 const labAddress = labDetails ? `http://${labDetails.address}:${labDetails.port}` : undefined;
165 this.e.log.nl();
166 this.e.log.info(`Development server running!` +
167 (labAddress ? `\nLab: ${color_1.strong(labAddress)}` : '') +
168 `\nLocal: ${color_1.strong(localAddress)}` +
169 (details.externalNetworkInterfaces.length > 0 ? `\nExternal: ${details.externalNetworkInterfaces.map(v => color_1.strong(fmtExternalAddress(v.address))).join(', ')}` : '') +
170 (devAppDetails && devAppDetails.channel ? `\nDevApp: ${color_1.strong(devAppDetails.channel)} on ${color_1.strong(os.hostname())}` : '') +
171 `\n\n${chalk_1.default.yellow('Use Ctrl+C to quit this process')}`);
172 this.e.log.nl();
173 if (options.open) {
174 const openAddress = labAddress ? labAddress : localAddress;
175 const openURL = this.modifyOpenURL(openAddress, options);
176 await open_1.open(openURL, { app: options.browser });
177 this.e.log.info(`Browser window opened to ${color_1.strong(openURL)}!`);
178 this.e.log.nl();
179 }
180 events_2.emit('serve:ready', details);
181 debug('serve details: %O', details);
182 this.scheduleAfterServe(options, details);
183 return details;
184 }
185 async afterServe(options, details) {
186 const hook = new ServeAfterHook(this.e);
187 try {
188 await hook.run({ name: hook.name, serve: lodash.assign({}, options, details) });
189 }
190 catch (e) {
191 if (e instanceof cli_framework_1.BaseError) {
192 throw new errors_1.FatalException(e.message);
193 }
194 throw e;
195 }
196 }
197 scheduleAfterServe(options, details) {
198 utils_process_1.onBeforeExit(async () => this.afterServe(options, details));
199 }
200 getUsedPorts(options, details) {
201 return [details.port];
202 }
203 async gatherDevAppDetails(options, details) {
204 if (options.devapp) {
205 const { computeBroadcastAddress } = await Promise.resolve().then(() => require('./devapp'));
206 // TODO: There is no accurate/reliable/realistic way to identify a WiFi
207 // network uniquely in NodeJS. But this is where we could detect new
208 // networks and prompt the dev if they want to "trust" it (allow binding to
209 // 0.0.0.0 and broadcasting).
210 const interfaces = utils_network_1.getExternalIPv4Interfaces()
211 .map(i => ({ ...i, broadcast: computeBroadcastAddress(i.address, i.netmask) }));
212 const { port } = details;
213 // the comm server always binds to 0.0.0.0 to target every possible interface
214 const commPort = await utils_network_1.findClosestOpenPort(exports.DEFAULT_DEVAPP_COMM_PORT);
215 return { port, commPort, interfaces };
216 }
217 }
218 async publishDevApp(options, details) {
219 if (options.devapp) {
220 const { createCommServer, createPublisher } = await Promise.resolve().then(() => require('./devapp'));
221 const publisher = await createPublisher(this.e.project.config.get('name'), details.port, details.commPort);
222 const comm = await createCommServer(publisher.id, details.commPort);
223 publisher.interfaces = details.interfaces;
224 comm.on('error', (err) => {
225 debug(`Error in DevApp service: ${String(err.stack ? err.stack : err)}`);
226 });
227 comm.on('connect', async (data) => {
228 this.e.log.info(`DevApp connection established from ${color_1.strong(data.device)}`);
229 this.e.log.nl();
230 if (!this.devAppConnectionMade) {
231 this.devAppConnectionMade = true;
232 await this.displayDevAppMessage(options);
233 }
234 });
235 publisher.on('error', (err) => {
236 debug(`Error in DevApp service: ${String(err.stack ? err.stack : err)}`);
237 });
238 try {
239 await comm.start();
240 }
241 catch (e) {
242 this.e.log.error(`Could not create DevApp comm server: ${String(e.stack ? e.stack : e)}`);
243 }
244 try {
245 await publisher.start();
246 }
247 catch (e) {
248 this.e.log.error(`Could not publish DevApp service: ${String(e.stack ? e.stack : e)}`);
249 }
250 return publisher.name;
251 }
252 }
253 async getSupportedDevAppPlugins() {
254 const p = path.resolve(constants_1.ASSETS_DIRECTORY, 'devapp', 'plugins.json');
255 const plugins = await utils_fs_1.readJson(p);
256 if (!Array.isArray(plugins)) {
257 throw new Error(`Cannot read ${p} file of supported plugins.`);
258 }
259 // This one is common, and hopefully obvious enough that the devapp doesn't
260 // use any splash screen but its own, so we mark it as "supported".
261 plugins.push('cordova-plugin-splashscreen');
262 return new Set(plugins);
263 }
264 async runLab(options, serveDetails) {
265 const labDetails = {
266 projectType: this.e.project.type,
267 address: options.labHost,
268 port: await utils_network_1.findClosestOpenPort(options.labPort),
269 };
270 const lab = new IonicLabServeCLI(this.e);
271 await lab.serve({ serveDetails, ...labDetails });
272 return labDetails;
273 }
274 async selectExternalIP(options) {
275 let availableInterfaces = [];
276 let chosenIP = options.address;
277 if (options.address === exports.BIND_ALL_ADDRESS) {
278 // ignore link-local addresses
279 availableInterfaces = utils_network_1.getExternalIPv4Interfaces().filter(i => !i.address.startsWith('169.254'));
280 if (availableInterfaces.length === 0) {
281 if (options.externalAddressRequired) {
282 throw new errors_1.FatalException(`No external network interfaces detected. In order to use the dev server externally you will need one.\n` +
283 `Are you connected to a local network?\n`);
284 }
285 }
286 else if (availableInterfaces.length === 1) {
287 chosenIP = availableInterfaces[0].address;
288 }
289 else if (availableInterfaces.length > 1) {
290 if (options.externalAddressRequired) {
291 if (this.e.flags.interactive) {
292 this.e.log.warn('Multiple network interfaces detected!\n' +
293 'You will be prompted to select an external-facing IP for the dev server that your device or emulator has access to.\n\n' +
294 `You may also use the ${color_1.input('--address')} option to skip this prompt.`);
295 const promptedIp = await this.e.prompt({
296 type: 'list',
297 name: 'promptedIp',
298 message: 'Please select which IP to use:',
299 choices: availableInterfaces.map(i => ({
300 name: `${i.address} ${color_1.weak(`(${i.device})`)}`,
301 value: i.address,
302 })),
303 });
304 chosenIP = promptedIp;
305 }
306 else {
307 throw new errors_1.FatalException(`Multiple network interfaces detected!\n` +
308 `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.`);
309 }
310 }
311 }
312 }
313 else if (options.externalAddressRequired && exports.LOCAL_ADDRESSES.includes(options.address)) {
314 this.e.log.warn('An external host may be required to serve for this target device/platform.\n' +
315 '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' +
316 `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`);
317 }
318 return [chosenIP, availableInterfaces];
319 }
320}
321exports.ServeRunner = ServeRunner;
322class ServeBeforeHook extends hooks_1.Hook {
323 constructor() {
324 super(...arguments);
325 this.name = 'serve:before';
326 }
327}
328class ServeAfterHook extends hooks_1.Hook {
329 constructor() {
330 super(...arguments);
331 this.name = 'serve:after';
332 }
333}
334class ServeCLI extends events_1.EventEmitter {
335 constructor(e) {
336 super();
337 this.e = e;
338 /**
339 * If true, the Serve CLI will not prompt to be installed.
340 */
341 this.global = false;
342 }
343 get resolvedProgram() {
344 if (this._resolvedProgram) {
345 return this._resolvedProgram;
346 }
347 return this.program;
348 }
349 /**
350 * Build the environment variables to be passed to the Serve CLI. Called by `this.start()`;
351 */
352 async buildEnvVars(options) {
353 return process.env;
354 }
355 /**
356 * Called whenever a line of stdout is received.
357 *
358 * If `false` is returned, the line is not emitted to the log.
359 *
360 * By default, the CLI is considered ready whenever stdout is emitted. This
361 * method should be overridden to more accurately portray readiness.
362 *
363 * @param line A line of stdout.
364 */
365 stdoutFilter(line) {
366 this.emit('ready');
367 return true;
368 }
369 /**
370 * Called whenever a line of stderr is received.
371 *
372 * If `false` is returned, the line is not emitted to the log.
373 */
374 stderrFilter(line) {
375 return true;
376 }
377 async resolveScript() {
378 if (typeof this.script === 'undefined') {
379 return;
380 }
381 const pkg = await this.e.project.requirePackageJson();
382 return pkg.scripts && pkg.scripts[this.script];
383 }
384 async serve(options) {
385 this._resolvedProgram = await this.resolveProgram();
386 await this.spawnWrapper(options);
387 const interval = setInterval(() => {
388 this.e.log.info(`Waiting for connectivity with ${color_1.input(this.resolvedProgram)}...`);
389 }, 5000);
390 debug('awaiting TCP connection to %s:%d', options.address, options.port);
391 await utils_network_1.isHostConnectable(options.address, options.port);
392 clearInterval(interval);
393 }
394 async spawnWrapper(options) {
395 try {
396 return await this.spawn(options);
397 }
398 catch (e) {
399 if (!(e instanceof errors_1.ServeCLIProgramNotFoundException)) {
400 throw e;
401 }
402 if (this.global) {
403 this.e.log.nl();
404 throw new errors_1.FatalException(`${color_1.input(this.pkg)} is required for this command to work properly.`);
405 }
406 this.e.log.nl();
407 this.e.log.info(`Looks like ${color_1.input(this.pkg)} isn't installed in this project.\n` +
408 `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.`);
409 const installed = await this.promptToInstall();
410 if (!installed) {
411 this.e.log.nl();
412 throw new errors_1.FatalException(`${color_1.input(this.pkg)} is required for this command to work properly.`);
413 }
414 return this.spawn(options);
415 }
416 }
417 async spawn(options) {
418 const args = await this.buildArgs(options);
419 const envVars = await this.buildEnvVars(options);
420 const p = await this.e.shell.spawn(this.resolvedProgram, args, { stdio: 'pipe', cwd: this.e.project.directory, env: utils_process_1.createProcessEnv(envVars) });
421 return new Promise((resolve, reject) => {
422 const errorHandler = (err) => {
423 debug('received error for %s: %o', this.resolvedProgram, err);
424 if (this.resolvedProgram === this.program && err.code === 'ENOENT') {
425 p.removeListener('close', closeHandler); // do not exit Ionic CLI, we can gracefully ask to install this CLI
426 reject(new errors_1.ServeCLIProgramNotFoundException(`${color_1.strong(this.resolvedProgram)} command not found.`));
427 }
428 else {
429 reject(err);
430 }
431 };
432 const closeHandler = (code) => {
433 if (code !== null) { // tslint:disable-line:no-null-keyword
434 debug('received unexpected close for %s (code: %d)', this.resolvedProgram, code);
435 this.e.log.nl();
436 this.e.log.error(`${color_1.input(this.resolvedProgram)} has unexpectedly closed (exit code ${code}).\n` +
437 'The Ionic CLI will exit. Please check any output above for error details.');
438 utils_process_1.processExit(1); // tslint:disable-line:no-floating-promises
439 }
440 };
441 p.on('error', errorHandler);
442 p.on('close', closeHandler);
443 utils_process_1.onBeforeExit(async () => {
444 p.removeListener('close', closeHandler);
445 if (p.pid) {
446 await utils_process_1.killProcessTree(p.pid);
447 }
448 });
449 const ws = this.createLoggerStream();
450 p.stdout.pipe(split2()).pipe(this.createStreamFilter(line => this.stdoutFilter(line))).pipe(ws);
451 p.stderr.pipe(split2()).pipe(this.createStreamFilter(line => this.stderrFilter(line))).pipe(ws);
452 this.once('ready', () => {
453 resolve();
454 });
455 });
456 }
457 createLoggerStream() {
458 const log = this.e.log.clone();
459 log.handlers = logger_1.createDefaultLoggerHandlers(cli_framework_1.createPrefixedFormatter(color_1.weak(`[${this.resolvedProgram === this.program ? this.prefix : this.resolvedProgram}]`)));
460 return log.createWriteStream(cli_framework_1.LOGGER_LEVELS.INFO);
461 }
462 async resolveProgram() {
463 if (typeof this.script !== 'undefined') {
464 debug(`Looking for ${color_1.ancillary(this.script)} npm script.`);
465 if (await this.resolveScript()) {
466 debug(`Using ${color_1.ancillary(this.script)} npm script.`);
467 return this.e.config.get('npmClient');
468 }
469 }
470 return this.program;
471 }
472 createStreamFilter(filter) {
473 return through2(function (chunk, enc, callback) {
474 const str = chunk.toString();
475 if (filter(str)) {
476 this.push(chunk);
477 }
478 callback();
479 });
480 }
481 async promptToInstall() {
482 const { pkgManagerArgs } = await Promise.resolve().then(() => require('./utils/npm'));
483 const [manager, ...managerArgs] = await pkgManagerArgs(this.e.config.get('npmClient'), { command: 'install', pkg: this.pkg, saveDev: true, saveExact: true });
484 this.e.log.nl();
485 const confirm = await this.e.prompt({
486 name: 'confirm',
487 message: `Install ${color_1.input(this.pkg)}?`,
488 type: 'confirm',
489 });
490 if (!confirm) {
491 this.e.log.warn(`Not installing--here's how to install manually: ${color_1.input(`${manager} ${managerArgs.join(' ')}`)}`);
492 return false;
493 }
494 await this.e.shell.run(manager, managerArgs, { cwd: this.e.project.directory });
495 return true;
496 }
497}
498exports.ServeCLI = ServeCLI;
499class PkgManagerServeCLI extends ServeCLI {
500 constructor() {
501 super(...arguments);
502 this.global = true;
503 this.script = exports.SERVE_SCRIPT;
504 }
505 async resolveProgram() {
506 return this.program;
507 }
508 async buildArgs(options) {
509 const { pkgManagerArgs } = await Promise.resolve().then(() => require('./utils/npm'));
510 // The Ionic CLI decides the host/port of the dev server, so --host and
511 // --port are provided to the downstream npm script as a best-effort
512 // attempt.
513 const args = {
514 _: [],
515 host: options.address,
516 port: options.port.toString(),
517 };
518 const scriptArgs = [...cli_framework_1.unparseArgs(args), ...options['--'] || []];
519 const [, ...pkgArgs] = await pkgManagerArgs(this.program, { command: 'run', script: this.script, scriptArgs });
520 return pkgArgs;
521 }
522}
523class NpmServeCLI extends PkgManagerServeCLI {
524 constructor() {
525 super(...arguments);
526 this.name = 'npm CLI';
527 this.pkg = 'npm';
528 this.program = 'npm';
529 this.prefix = 'npm';
530 }
531}
532exports.NpmServeCLI = NpmServeCLI;
533class YarnServeCLI extends PkgManagerServeCLI {
534 constructor() {
535 super(...arguments);
536 this.name = 'Yarn';
537 this.pkg = 'yarn';
538 this.program = 'yarn';
539 this.prefix = 'yarn';
540 }
541}
542exports.YarnServeCLI = YarnServeCLI;
543class IonicLabServeCLI extends ServeCLI {
544 constructor() {
545 super(...arguments);
546 this.name = 'Ionic Lab';
547 this.pkg = '@ionic/lab';
548 this.program = 'ionic-lab';
549 this.prefix = 'lab';
550 this.script = undefined;
551 }
552 stdoutFilter(line) {
553 if (line.includes('running')) {
554 this.emit('ready');
555 }
556 return false; // no stdout
557 }
558 async buildArgs(options) {
559 const { serveDetails, ...labDetails } = options;
560 const pkg = await this.e.project.requirePackageJson();
561 const url = `${serveDetails.protocol}://localhost:${serveDetails.port}`;
562 const appName = this.e.project.config.get('name');
563 const labArgs = [url, '--host', labDetails.address, '--port', String(labDetails.port), '--project-type', labDetails.projectType];
564 const nameArgs = appName ? ['--app-name', appName] : [];
565 const versionArgs = pkg.version ? ['--app-version', pkg.version] : [];
566 return [...labArgs, ...nameArgs, ...versionArgs];
567 }
568}