UNPKG

@ionic/cli-utils

Version:
532 lines (531 loc) 25.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); const cli_framework_1 = require("@ionic/cli-framework"); const process_1 = require("@ionic/cli-framework/utils/process"); 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 chalk_1 = 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 errors_1 = require("./errors"); const events_2 = require("./events"); const hooks_1 = require("./hooks"); const logger_1 = require("./utils/logger"); const debug = Debug('ionic:cli-utils: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.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: 'address', summary: 'Use specific address for the dev server', default: exports.BIND_ALL_ADDRESS, groups: [cli_framework_1.OptionGroup.Advanced], }, { name: 'port', summary: 'Use specific port for HTTP', default: exports.DEFAULT_SERVER_PORT.toString(), aliases: ['p'], groups: [cli_framework_1.OptionGroup.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 => chalk_1.default.green(e)).join(', ')})`, groups: [cli_framework_1.OptionGroup.Hidden, cli_framework_1.OptionGroup.Advanced], }, { name: 'platform', summary: `Target platform on chosen engine (e.g. ${['ios', 'android'].map(e => chalk_1.default.green(e)).join(', ')})`, groups: [cli_framework_1.OptionGroup.Hidden, cli_framework_1.OptionGroup.Advanced], }, ]; class ServeRunner { constructor() { this.devAppConnectionMade = false; } createOptionsFromCommandLine(inputs, options) { const separatedArgs = options['--']; if (options['local']) { options['address'] = 'localhost'; options['devapp'] = false; } const engine = this.determineEngineFromCommandLine(options); const address = options['address'] ? String(options['address']) : exports.BIND_ALL_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: engine === 'browser' && (typeof options['devapp'] === 'undefined' || options['devapp']) ? true : false, engine, externalAddressRequired: options['externalAddressRequired'] ? true : false, lab: options['lab'] ? true : false, labHost: options['lab-host'] ? String(options['lab-host']) : 'localhost', labPort, livereload: typeof options['livereload'] === 'boolean' ? Boolean(options['livereload']) : true, open: options['open'] ? true : false, platform: options['platform'] ? String(options['platform']) : undefined, port, proxy: typeof options['proxy'] === 'boolean' ? Boolean(options['proxy']) : true, ssl: false, project: options['project'] ? String(options['project']) : undefined, }; } determineEngineFromCommandLine(options) { if (options['engine']) { return String(options['engine']); } if (options['cordova']) { return 'cordova'; } return 'browser'; } displayDevAppMessage(options) { return tslib_1.__awaiter(this, void 0, void 0, function* () { const pkg = yield 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 = yield 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 => `- ${chalk_1.default.bold(p)}`).join('\n')}\n\n` + `App may not function as expected in Ionic DevApp.`); } } }); } beforeServe(options) { return tslib_1.__awaiter(this, void 0, void 0, function* () { const hook = new ServeBeforeHook(this.e); try { yield 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; } }); } run(options) { return tslib_1.__awaiter(this, void 0, void 0, function* () { yield this.beforeServe(options); const details = yield this.serveProject(options); const devAppDetails = yield this.gatherDevAppDetails(options, details); const labDetails = options.lab ? yield this.runLab(options, details) : undefined; if (devAppDetails) { const devAppName = yield 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 ? `${labDetails.protocol}://${labDetails.address}:${labDetails.port}` : undefined; this.e.log.nl(); this.e.log.info(`Development server running!` + (labAddress ? `\nLab: ${chalk_1.default.bold(labAddress)}` : '') + `\nLocal: ${chalk_1.default.bold(localAddress)}` + (details.externalNetworkInterfaces.length > 0 ? `\nExternal: ${details.externalNetworkInterfaces.map(v => chalk_1.default.bold(fmtExternalAddress(v.address))).join(', ')}` : '') + (devAppDetails && devAppDetails.channel ? `\nDevApp: ${chalk_1.default.bold(devAppDetails.channel)} on ${chalk_1.default.bold(os.hostname())}` : '') + `\n\n${chalk_1.default.yellow('Use Ctrl+C to quit this process')}`); this.e.log.nl(); if (options.open) { const openAddress = labAddress ? labAddress : localAddress; const openURL = this.modifyOpenURL(openAddress, options); const opn = yield Promise.resolve().then(() => require('opn')); yield opn(openURL, { app: options.browser, wait: false }); this.e.log.info(`Browser window opened to ${chalk_1.default.bold(openURL)}!`); this.e.log.nl(); } events_2.emit('serve:ready', details); this.scheduleAfterServe(options, details); return details; }); } afterServe(options, details) { return tslib_1.__awaiter(this, void 0, void 0, function* () { const hook = new ServeAfterHook(this.e); try { yield 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) { process_1.onBeforeExit(() => tslib_1.__awaiter(this, void 0, void 0, function* () { return this.afterServe(options, details); })); } gatherDevAppDetails(options, details) { return tslib_1.__awaiter(this, void 0, void 0, function* () { if (options.devapp) { const { computeBroadcastAddress } = yield 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 => (Object.assign({}, 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 = yield utils_network_1.findClosestOpenPort(exports.DEFAULT_DEVAPP_COMM_PORT); return { port, commPort, interfaces }; } }); } publishDevApp(options, details) { return tslib_1.__awaiter(this, void 0, void 0, function* () { if (options.devapp) { const { createCommServer, createPublisher } = yield Promise.resolve().then(() => require('./devapp')); const publisher = yield createPublisher(this.e.project.config.get('name'), details.port, details.commPort); const comm = yield 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', (data) => tslib_1.__awaiter(this, void 0, void 0, function* () { if (!this.devAppConnectionMade) { this.devAppConnectionMade = true; yield this.displayDevAppMessage(options); } this.e.log.info(`DevApp connection established from ${chalk_1.default.bold(data.email)}`); })); publisher.on('error', (err) => { debug(`Error in DevApp service: ${String(err.stack ? err.stack : err)}`); }); try { yield comm.start(); } catch (e) { this.e.log.error(`Could not create DevApp comm server: ${String(e.stack ? e.stack : e)}`); } try { yield publisher.start(); } catch (e) { this.e.log.error(`Could not publish DevApp service: ${String(e.stack ? e.stack : e)}`); } return publisher.name; } }); } getSupportedDevAppPlugins() { return tslib_1.__awaiter(this, void 0, void 0, function* () { const p = path.resolve(constants_1.ASSETS_DIRECTORY, 'devapp', 'plugins.json'); const plugins = yield utils_fs_1.readJsonFile(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); }); } runLab(options, serveDetails) { return tslib_1.__awaiter(this, void 0, void 0, function* () { const labDetails = { protocol: options.ssl ? 'https' : 'http', address: options.labHost, port: yield utils_network_1.findClosestOpenPort(options.labPort), }; if (options.ssl) { const sslConfig = this.e.project.config.get('ssl'); if (sslConfig && sslConfig.key && sslConfig.cert) { labDetails.ssl = { key: sslConfig.key, cert: sslConfig.cert }; } else { throw new errors_1.FatalException(`Both ${chalk_1.default.green('ssl.key')} and ${chalk_1.default.green('ssl.cert')} config entries must be set.\n` + `See ${chalk_1.default.green('ionic serve --help')} for details on using your own SSL key and certificate for Ionic Lab and the dev server.`); } } const lab = new IonicLabServeCLI(this.e); yield lab.serve(Object.assign({ serveDetails }, labDetails)); return labDetails; }); } selectExternalIP(options) { return tslib_1.__awaiter(this, void 0, void 0, function* () { let availableInterfaces = []; let chosenIP = options.address; if (options.address === exports.BIND_ALL_ADDRESS) { availableInterfaces = utils_network_1.getExternalIPv4Interfaces(); 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 ${chalk_1.default.green('--address')} option to skip this prompt.`); const promptedIp = yield this.e.prompt({ type: 'list', name: 'promptedIp', message: 'Please select which IP to use:', choices: availableInterfaces.map(i => ({ name: `${i.address} ${chalk_1.default.dim(`(${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 ${chalk_1.default.green('--address')} option.`); } } } } 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; this.resolvedProgram = this.program; } /** * 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; } serve(options) { return tslib_1.__awaiter(this, void 0, void 0, function* () { this.resolvedProgram = yield this.resolveProgram(); yield this.spawnWrapper(options); const interval = setInterval(() => { this.e.log.info(`Waiting for connectivity with ${chalk_1.default.green(this.resolvedProgram)}...`); }, 5000); debug('awaiting TCP connection to %s:%d', options.address, options.port); yield utils_network_1.isHostConnectable(options.address, options.port); clearInterval(interval); }); } spawnWrapper(options) { return tslib_1.__awaiter(this, void 0, void 0, function* () { try { return yield this.spawn(options); } catch (e) { if (!(e instanceof errors_1.ServeCLIProgramNotFoundException)) { throw e; } this.e.log.nl(); this.e.log.info(`Looks like ${chalk_1.default.green(this.pkg)} isn't installed in this project.\n` + `This package is required for this command to work properly.`); const installed = yield this.promptToInstall(); if (!installed) { this.e.log.nl(); throw new errors_1.FatalException(`${chalk_1.default.green(this.pkg)} is required for this command to work properly.`); } return this.spawn(options); } }); } spawn(options) { return tslib_1.__awaiter(this, void 0, void 0, function* () { const args = yield this.buildArgs(options); const p = this.e.shell.spawn(this.resolvedProgram, args, { stdio: 'pipe', cwd: this.e.project.directory }); 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(`${chalk_1.default.bold(this.resolvedProgram)} command not found.`)); } else { reject(err); } }; const closeHandler = (code, signal) => { debug('received unexpected close for %s (code: %d, signal: %s)', this.resolvedProgram, code, signal); this.e.log.nl(); this.e.log.error(`A utility CLI has unexpectedly closed (exit code ${code}).\n` + 'The Ionic CLI will exit. Please check any output above for error details.'); process_1.processExit(1); // tslint:disable-line:no-floating-promises }; p.on('error', errorHandler); p.on('close', closeHandler); process_1.onBeforeExit(() => tslib_1.__awaiter(this, void 0, void 0, function* () { p.removeListener('close', closeHandler); if (p.pid) { yield 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(chalk_1.default.dim(`[${this.resolvedProgram === this.program ? this.prefix : this.resolvedProgram}]`))); return log.createWriteStream(cli_framework_1.LOGGER_LEVELS.INFO); } resolveProgram() { return tslib_1.__awaiter(this, void 0, void 0, function* () { if (typeof this.script !== 'undefined') { debug(`Looking for ${chalk_1.default.cyan(this.script)} npm script.`); const pkg = yield this.e.project.requirePackageJson(); if (pkg.scripts && pkg.scripts[this.script]) { debug(`Using ${chalk_1.default.cyan(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(); }); } promptToInstall() { return tslib_1.__awaiter(this, void 0, void 0, function* () { const { pkgManagerArgs } = yield Promise.resolve().then(() => require('./utils/npm')); const [manager, ...managerArgs] = yield pkgManagerArgs(this.e.config.get('npmClient'), { command: 'install', pkg: this.pkg, saveDev: true, saveExact: true }); this.e.log.nl(); const confirm = yield this.e.prompt({ name: 'confirm', message: `Install ${chalk_1.default.green(this.pkg)}?`, type: 'confirm', }); if (!confirm) { this.e.log.warn(`Not installing--here's how to install manually: ${chalk_1.default.green(`${manager} ${managerArgs.join(' ')}`)}`); return false; } yield this.e.shell.run(manager, managerArgs, { cwd: this.e.project.directory }); return true; }); } } exports.ServeCLI = ServeCLI; 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 } buildArgs(options) { return tslib_1.__awaiter(this, void 0, void 0, function* () { const { serveDetails } = options, labDetails = tslib_1.__rest(options, ["serveDetails"]); const pkg = yield 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)]; const nameArgs = appName ? ['--app-name', appName] : []; const versionArgs = pkg.version ? ['--app-version', pkg.version] : []; if (labDetails.ssl) { labArgs.push('--ssl', '--ssl-key', labDetails.ssl.key, '--ssl-cert', labDetails.ssl.cert); } return [...labArgs, ...nameArgs, ...versionArgs]; }); } } function serve(deps, inputs, options) { return tslib_1.__awaiter(this, void 0, void 0, function* () { try { const runner = yield deps.project.requireServeRunner(); if (deps.project.name) { options['project'] = deps.project.name; } const opts = runner.createOptionsFromCommandLine(inputs, options); const details = yield runner.run(opts); return details; } catch (e) { if (e instanceof errors_1.RunnerException) { throw new errors_1.FatalException(e.message); } throw e; } }); } exports.serve = serve;