UNPKG

ionic

Version:

A tool for creating and developing Ionic Framework mobile apps.

574 lines (573 loc) • 25.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const cli_framework_1 = require("@ionic/cli-framework"); const string_1 = require("@ionic/cli-framework/utils/string"); const utils_fs_1 = require("@ionic/utils-fs"); const utils_network_1 = require("@ionic/utils-network"); const utils_process_1 = require("@ionic/utils-process"); const chalk = require("chalk"); const Debug = require("debug"); const events_1 = require("events"); const lodash = require("lodash"); const os = require("os"); const path = require("path"); const split2 = require("split2"); const through2 = require("through2"); const constants_1 = require("../constants"); const guards_1 = require("../guards"); const color_1 = require("./color"); const errors_1 = require("./errors"); const events_2 = require("./events"); const hooks_1 = require("./hooks"); const open_1 = require("./open"); const logger_1 = require("./utils/logger"); const debug = Debug('ionic:lib:serve'); exports.DEFAULT_DEV_LOGGER_PORT = 53703; exports.DEFAULT_LIVERELOAD_PORT = 35729; exports.DEFAULT_SERVER_PORT = 8100; exports.DEFAULT_LAB_PORT = 8200; exports.DEFAULT_DEVAPP_COMM_PORT = 53233; exports.DEFAULT_ADDRESS = 'localhost'; exports.BIND_ALL_ADDRESS = '0.0.0.0'; exports.LOCAL_ADDRESSES = ['localhost', '127.0.0.1']; exports.BROWSERS = ['safari', 'firefox', process.platform === 'win32' ? 'chrome' : (process.platform === 'darwin' ? 'google chrome' : 'google-chrome')]; // npm script name exports.SERVE_SCRIPT = 'ionic:serve'; exports.COMMON_SERVE_COMMAND_OPTIONS = [ { name: 'external', summary: `Host dev server on all network interfaces (i.e. ${color_1.input('--address=0.0.0.0')})`, type: Boolean, }, { name: 'address', summary: 'Use specific address for the dev server', default: exports.DEFAULT_ADDRESS, groups: ["advanced" /* ADVANCED */], }, { name: 'port', summary: 'Use specific port for HTTP', default: exports.DEFAULT_SERVER_PORT.toString(), aliases: ['p'], groups: ["advanced" /* ADVANCED */], }, { name: 'livereload', summary: 'Do not spin up dev server--just serve files', type: Boolean, default: true, }, { name: 'engine', summary: `Target engine (e.g. ${['browser', 'cordova'].map(e => color_1.input(e)).join(', ')})`, groups: ["hidden" /* HIDDEN */, "advanced" /* ADVANCED */], }, { name: 'platform', summary: `Target platform on chosen engine (e.g. ${['ios', 'android'].map(e => color_1.input(e)).join(', ')})`, groups: ["hidden" /* HIDDEN */, "advanced" /* ADVANCED */], }, { name: 'devapp', summary: 'Publish DevApp service', type: Boolean, default: false, groups: ["advanced" /* ADVANCED */], }, ]; class ServeRunner { constructor() { this.devAppConnectionMade = false; } getPkgManagerServeCLI() { return this.e.config.get('npmClient') === 'npm' ? new NpmServeCLI(this.e) : new YarnServeCLI(this.e); } createOptionsFromCommandLine(inputs, options) { const separatedArgs = options['--']; if (options['external'] || (options['devapp'] && options['address'] === exports.DEFAULT_ADDRESS)) { options['address'] = '0.0.0.0'; } const engine = this.determineEngineFromCommandLine(options); const address = options['address'] ? String(options['address']) : exports.DEFAULT_ADDRESS; const labPort = string_1.str2num(options['lab-port'], exports.DEFAULT_LAB_PORT); const port = string_1.str2num(options['port'], exports.DEFAULT_SERVER_PORT); return { '--': separatedArgs ? separatedArgs : [], address, browser: options['browser'] ? String(options['browser']) : undefined, browserOption: options['browseroption'] ? String(options['browseroption']) : undefined, devapp: !!options['devapp'], engine, externalAddressRequired: !!options['externalAddressRequired'], lab: !!options['lab'], labHost: options['lab-host'] ? String(options['lab-host']) : 'localhost', labPort, livereload: typeof options['livereload'] === 'boolean' ? Boolean(options['livereload']) : true, open: !!options['open'], platform: options['platform'] ? String(options['platform']) : undefined, port, proxy: typeof options['proxy'] === 'boolean' ? Boolean(options['proxy']) : true, project: options['project'] ? String(options['project']) : undefined, verbose: !!options['verbose'], }; } determineEngineFromCommandLine(options) { if (options['engine']) { return String(options['engine']); } if (options['cordova']) { return 'cordova'; } return 'browser'; } async displayDevAppMessage(options) { const pkg = await this.e.project.requirePackageJson(); // If this is regular `ionic serve`, we warn the dev about unsupported // plugins in the devapp. if (options.devapp && guards_1.isCordovaPackageJson(pkg)) { const plugins = await this.getSupportedDevAppPlugins(); const packageCordovaPlugins = Object.keys(pkg.cordova.plugins); const packageCordovaPluginsDiff = packageCordovaPlugins.filter(p => !plugins.has(p)); if (packageCordovaPluginsDiff.length > 0) { this.e.log.warn('Detected unsupported Cordova plugins with Ionic DevApp:\n' + `${packageCordovaPluginsDiff.map(p => `- ${color_1.strong(p)}`).join('\n')}\n\n` + `App may not function as expected in Ionic DevApp.`); this.e.log.nl(); } } } async beforeServe(options) { const hook = new ServeBeforeHook(this.e); try { await hook.run({ name: hook.name, serve: options }); } catch (e) { if (e instanceof cli_framework_1.BaseError) { throw new errors_1.FatalException(e.message); } throw e; } } async run(options) { debug('serve options: %O', options); await this.beforeServe(options); if (options.devapp) { this.e.log.warn(`The DevApp has been retired.\n` + `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` + `${color_1.ancillary('[1]')}: ${color_1.strong('https://ionicframework.com/docs/appflow/devapp')}\n`); } const details = await this.serveProject(options); const devAppDetails = await this.gatherDevAppDetails(options, details); const labDetails = options.lab ? await this.runLab(options, details) : undefined; if (devAppDetails) { const devAppName = await this.publishDevApp(options, devAppDetails); devAppDetails.channel = devAppName; } const localAddress = `${details.protocol}://localhost:${details.port}`; const fmtExternalAddress = (address) => `${details.protocol}://${address}:${details.port}`; const labAddress = labDetails ? `http://${labDetails.address}:${labDetails.port}` : undefined; this.e.log.nl(); this.e.log.info(`Development server running!` + (labAddress ? `\nLab: ${color_1.strong(labAddress)}` : '') + `\nLocal: ${color_1.strong(localAddress)}` + (details.externalNetworkInterfaces.length > 0 ? `\nExternal: ${details.externalNetworkInterfaces.map(v => color_1.strong(fmtExternalAddress(v.address))).join(', ')}` : '') + (devAppDetails && devAppDetails.channel ? `\nDevApp: ${color_1.strong(devAppDetails.channel)} on ${color_1.strong(os.hostname())}` : '') + `\n\n${chalk.yellow('Use Ctrl+C to quit this process')}`); this.e.log.nl(); if (options.open) { const openAddress = labAddress ? labAddress : localAddress; const url = this.modifyOpenUrl(openAddress, options); await open_1.openUrl(url, { app: options.browser }); this.e.log.info(`Browser window opened to ${color_1.strong(url)}!`); this.e.log.nl(); } events_2.emit('serve:ready', details); debug('serve details: %O', details); this.scheduleAfterServe(options, details); return details; } async afterServe(options, details) { const hook = new ServeAfterHook(this.e); try { await hook.run({ name: hook.name, serve: lodash.assign({}, options, details) }); } catch (e) { if (e instanceof cli_framework_1.BaseError) { throw new errors_1.FatalException(e.message); } throw e; } } scheduleAfterServe(options, details) { utils_process_1.onBeforeExit(async () => this.afterServe(options, details)); } getUsedPorts(options, details) { return [details.port]; } async gatherDevAppDetails(options, details) { if (options.devapp) { const { computeBroadcastAddress } = await Promise.resolve().then(() => require('./devapp')); // TODO: There is no accurate/reliable/realistic way to identify a WiFi // network uniquely in NodeJS. But this is where we could detect new // networks and prompt the dev if they want to "trust" it (allow binding to // 0.0.0.0 and broadcasting). const interfaces = utils_network_1.getExternalIPv4Interfaces() .map(i => ({ ...i, broadcast: computeBroadcastAddress(i.address, i.netmask) })); const { port } = details; // the comm server always binds to 0.0.0.0 to target every possible interface const commPort = await utils_network_1.findClosestOpenPort(exports.DEFAULT_DEVAPP_COMM_PORT); return { port, commPort, interfaces }; } } async publishDevApp(options, details) { if (options.devapp) { const { createCommServer, createPublisher } = await Promise.resolve().then(() => require('./devapp')); const publisher = await createPublisher(this.e.project.config.get('name'), details.port, details.commPort); const comm = await createCommServer(publisher.id, details.commPort); publisher.interfaces = details.interfaces; comm.on('error', (err) => { debug(`Error in DevApp service: ${String(err.stack ? err.stack : err)}`); }); comm.on('connect', async (data) => { this.e.log.info(`DevApp connection established from ${color_1.strong(data.device)}`); this.e.log.nl(); if (!this.devAppConnectionMade) { this.devAppConnectionMade = true; await this.displayDevAppMessage(options); } }); publisher.on('error', (err) => { debug(`Error in DevApp service: ${String(err.stack ? err.stack : err)}`); }); try { await comm.start(); } catch (e) { this.e.log.error(`Could not create DevApp comm server: ${String(e.stack ? e.stack : e)}`); } try { await publisher.start(); } catch (e) { this.e.log.error(`Could not publish DevApp service: ${String(e.stack ? e.stack : e)}`); } return publisher.name; } } async getSupportedDevAppPlugins() { const p = path.resolve(constants_1.ASSETS_DIRECTORY, 'devapp', 'plugins.json'); const plugins = await utils_fs_1.readJson(p); if (!Array.isArray(plugins)) { throw new Error(`Cannot read ${p} file of supported plugins.`); } // This one is common, and hopefully obvious enough that the devapp doesn't // use any splash screen but its own, so we mark it as "supported". plugins.push('cordova-plugin-splashscreen'); return new Set(plugins); } async runLab(options, serveDetails) { const labDetails = { projectType: this.e.project.type, address: options.labHost, port: await utils_network_1.findClosestOpenPort(options.labPort), }; const lab = new IonicLabServeCLI(this.e); await lab.serve({ serveDetails, ...labDetails }); return labDetails; } async selectExternalIP(options) { let availableInterfaces = []; let chosenIP = options.address; if (options.address === exports.BIND_ALL_ADDRESS) { // ignore link-local addresses availableInterfaces = utils_network_1.getExternalIPv4Interfaces().filter(i => !i.address.startsWith('169.254')); if (availableInterfaces.length === 0) { if (options.externalAddressRequired) { throw new errors_1.FatalException(`No external network interfaces detected. In order to use the dev server externally you will need one.\n` + `Are you connected to a local network?\n`); } } else if (availableInterfaces.length === 1) { chosenIP = availableInterfaces[0].address; } else if (availableInterfaces.length > 1) { if (options.externalAddressRequired) { if (this.e.flags.interactive) { this.e.log.warn('Multiple network interfaces detected!\n' + 'You will be prompted to select an external-facing IP for the dev server that your device or emulator has access to.\n\n' + `You may also use the ${color_1.input('--address')} option to skip this prompt.`); const promptedIp = await this.e.prompt({ type: 'list', name: 'promptedIp', message: 'Please select which IP to use:', choices: availableInterfaces.map(i => ({ name: `${i.address} ${color_1.weak(`(${i.device})`)}`, value: i.address, })), }); chosenIP = promptedIp; } else { throw new errors_1.FatalException(`Multiple network interfaces detected!\n` + `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.`); } } } } else if (options.externalAddressRequired && exports.LOCAL_ADDRESSES.includes(options.address)) { this.e.log.warn('An external host may be required to serve for this target device/platform.\n' + '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' + `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`); } return [chosenIP, availableInterfaces]; } } exports.ServeRunner = ServeRunner; class ServeBeforeHook extends hooks_1.Hook { constructor() { super(...arguments); this.name = 'serve:before'; } } class ServeAfterHook extends hooks_1.Hook { constructor() { super(...arguments); this.name = 'serve:after'; } } class ServeCLI extends events_1.EventEmitter { constructor(e) { super(); this.e = e; /** * If true, the Serve CLI will not prompt to be installed. */ this.global = false; } get resolvedProgram() { if (this._resolvedProgram) { return this._resolvedProgram; } return this.program; } /** * Build the environment variables to be passed to the Serve CLI. Called by `this.start()`; */ async buildEnvVars(options) { return process.env; } /** * Called whenever a line of stdout is received. * * If `false` is returned, the line is not emitted to the log. * * By default, the CLI is considered ready whenever stdout is emitted. This * method should be overridden to more accurately portray readiness. * * @param line A line of stdout. */ stdoutFilter(line) { this.emit('ready'); return true; } /** * Called whenever a line of stderr is received. * * If `false` is returned, the line is not emitted to the log. */ stderrFilter(line) { return true; } async resolveScript() { if (typeof this.script === 'undefined') { return; } const pkg = await this.e.project.requirePackageJson(); return pkg.scripts && pkg.scripts[this.script]; } async serve(options) { this._resolvedProgram = await this.resolveProgram(); await this.spawnWrapper(options); const interval = setInterval(() => { this.e.log.info(`Waiting for connectivity with ${color_1.input(this.resolvedProgram)}...`); }, 5000); debug('awaiting TCP connection to %s:%d', options.address, options.port); await utils_network_1.isHostConnectable(options.address, options.port); clearInterval(interval); } async spawnWrapper(options) { try { return await this.spawn(options); } catch (e) { if (!(e instanceof errors_1.ServeCLIProgramNotFoundException)) { throw e; } if (this.global) { this.e.log.nl(); throw new errors_1.FatalException(`${color_1.input(this.pkg)} is required for this command to work properly.`); } this.e.log.nl(); this.e.log.info(`Looks like ${color_1.input(this.pkg)} isn't installed in this project.\n` + `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.`); const installed = await this.promptToInstall(); if (!installed) { this.e.log.nl(); throw new errors_1.FatalException(`${color_1.input(this.pkg)} is required for this command to work properly.`); } return this.spawn(options); } } async spawn(options) { const args = await this.buildArgs(options); const env = await this.buildEnvVars(options); const p = await this.e.shell.spawn(this.resolvedProgram, args, { stdio: 'pipe', cwd: this.e.project.directory, env: utils_process_1.createProcessEnv(env) }); return new Promise((resolve, reject) => { const errorHandler = (err) => { debug('received error for %s: %o', this.resolvedProgram, err); if (this.resolvedProgram === this.program && err.code === 'ENOENT') { p.removeListener('close', closeHandler); // do not exit Ionic CLI, we can gracefully ask to install this CLI reject(new errors_1.ServeCLIProgramNotFoundException(`${color_1.strong(this.resolvedProgram)} command not found.`)); } else { reject(err); } }; const closeHandler = (code) => { if (code !== null) { // tslint:disable-line:no-null-keyword debug('received unexpected close for %s (code: %d)', this.resolvedProgram, code); this.e.log.nl(); this.e.log.error(`${color_1.input(this.resolvedProgram)} has unexpectedly closed (exit code ${code}).\n` + 'The Ionic CLI will exit. Please check any output above for error details.'); utils_process_1.processExit(1); // tslint:disable-line:no-floating-promises } }; p.on('error', errorHandler); p.on('close', closeHandler); utils_process_1.onBeforeExit(async () => { p.removeListener('close', closeHandler); if (p.pid) { await utils_process_1.killProcessTree(p.pid); } }); const ws = this.createLoggerStream(); p.stdout.pipe(split2()).pipe(this.createStreamFilter(line => this.stdoutFilter(line))).pipe(ws); p.stderr.pipe(split2()).pipe(this.createStreamFilter(line => this.stderrFilter(line))).pipe(ws); this.once('ready', () => { resolve(); }); }); } createLoggerStream() { const log = this.e.log.clone(); log.handlers = logger_1.createDefaultLoggerHandlers(cli_framework_1.createPrefixedFormatter(color_1.weak(`[${this.resolvedProgram === this.program ? this.prefix : this.resolvedProgram}]`))); return log.createWriteStream(cli_framework_1.LOGGER_LEVELS.INFO); } async resolveProgram() { if (typeof this.script !== 'undefined') { debug(`Looking for ${color_1.ancillary(this.script)} npm script.`); if (await this.resolveScript()) { debug(`Using ${color_1.ancillary(this.script)} npm script.`); return this.e.config.get('npmClient'); } } return this.program; } createStreamFilter(filter) { return through2(function (chunk, enc, callback) { const str = chunk.toString(); if (filter(str)) { this.push(chunk); } callback(); }); } async promptToInstall() { const { pkgManagerArgs } = await Promise.resolve().then(() => require('./utils/npm')); const [manager, ...managerArgs] = await pkgManagerArgs(this.e.config.get('npmClient'), { command: 'install', pkg: this.pkg, saveDev: true, saveExact: true }); this.e.log.nl(); const confirm = await this.e.prompt({ name: 'confirm', message: `Install ${color_1.input(this.pkg)}?`, type: 'confirm', }); if (!confirm) { this.e.log.warn(`Not installing--here's how to install manually: ${color_1.input(`${manager} ${managerArgs.join(' ')}`)}`); return false; } await this.e.shell.run(manager, managerArgs, { cwd: this.e.project.directory }); return true; } } exports.ServeCLI = ServeCLI; class PkgManagerServeCLI extends ServeCLI { constructor() { super(...arguments); this.global = true; this.script = exports.SERVE_SCRIPT; } async resolveProgram() { return this.program; } async buildArgs(options) { const { pkgManagerArgs } = await Promise.resolve().then(() => require('./utils/npm')); // The Ionic CLI decides the host/port of the dev server, so --host and // --port are provided to the downstream npm script as a best-effort // attempt. const args = { _: [], host: options.address, port: options.port.toString(), }; const scriptArgs = [...cli_framework_1.unparseArgs(args), ...options['--'] || []]; const [, ...pkgArgs] = await pkgManagerArgs(this.program, { command: 'run', script: this.script, scriptArgs }); return pkgArgs; } } class NpmServeCLI extends PkgManagerServeCLI { constructor() { super(...arguments); this.name = 'npm CLI'; this.pkg = 'npm'; this.program = 'npm'; this.prefix = 'npm'; } } exports.NpmServeCLI = NpmServeCLI; class YarnServeCLI extends PkgManagerServeCLI { constructor() { super(...arguments); this.name = 'Yarn'; this.pkg = 'yarn'; this.program = 'yarn'; this.prefix = 'yarn'; } } exports.YarnServeCLI = YarnServeCLI; class IonicLabServeCLI extends ServeCLI { constructor() { super(...arguments); this.name = 'Ionic Lab'; this.pkg = '@ionic/lab'; this.program = 'ionic-lab'; this.prefix = 'lab'; this.script = undefined; } stdoutFilter(line) { if (line.includes('running')) { this.emit('ready'); } return false; // no stdout } async buildArgs(options) { const { serveDetails, ...labDetails } = options; const pkg = await this.e.project.requirePackageJson(); const url = `${serveDetails.protocol}://localhost:${serveDetails.port}`; const appName = this.e.project.config.get('name'); const labArgs = [url, '--host', labDetails.address, '--port', String(labDetails.port), '--project-type', labDetails.projectType]; const nameArgs = appName ? ['--app-name', appName] : []; const versionArgs = pkg.version ? ['--app-version', pkg.version] : []; return [...labArgs, ...nameArgs, ...versionArgs]; } }