1import chalk from 'chalk'; 2import crypto from 'crypto'; 3import * as path from 'path'; 4import slugify from 'slugify'; 5 6import UserSettings from '../../api/user/UserSettings'; 7import { getActorDisplayName, getUserAsync } from '../../api/user/user'; 8import * as Log from '../../log'; 9import { delayAsync, resolveWithTimeout } from '../../utils/delay'; 10import { env } from '../../utils/env'; 11import { CommandError } from '../../utils/errors'; 12import { isNgrokClientError, NgrokInstance, NgrokResolver } from '../doctor/ngrok/NgrokResolver'; 13import { hasAdbReverseAsync, startAdbReverseAsync } from '../platforms/android/adbReverse'; 14import { ProjectSettings } from '../project/settings'; 15 16const debug = require('debug')('expo:start:server:ngrok') as typeof console.log; 17 18const NGROK_CONFIG = { 19 authToken: '5W1bR67GNbWcXqmxZzBG1_56GezNeaX6sSRvn8npeQ8', 20 domain: 'exp.direct', 21}; 22 23const TUNNEL_TIMEOUT = 10 * 1000; 24 25export class AsyncNgrok { 26 /** Resolves the best instance of ngrok, exposed for testing. */ 27 resolver: NgrokResolver; 28 29 /** Info about the currently running instance of ngrok. */ 30 private serverUrl: string | null = null; 31 32 constructor( 33 private projectRoot: string, 34 private port: number 35 ) { 36 this.resolver = new NgrokResolver(projectRoot); 37 } 38 39 public getActiveUrl(): string | null { 40 return this.serverUrl; 41 } 42 43 /** Exposed for testing. */ 44 async _getIdentifyingUrlSegmentsAsync(): Promise<string[]> { 45 const user = await getUserAsync(); 46 if (user?.__typename === 'Robot') { 47 throw new CommandError('NGROK_ROBOT', 'Cannot use ngrok with a robot user.'); 48 } 49 const username = getActorDisplayName(user); 50 51 return [ 52 // NOTE: https://github.com/expo/expo/pull/16556#discussion_r822944286 53 await this.getProjectRandomnessAsync(), 54 slugify(username), 55 // Use the port to distinguish between multiple tunnels (webpack, metro). 56 String(this.port), 57 ]; 58 } 59 60 /** Exposed for testing. */ 61 async _getProjectHostnameAsync(): Promise<string> { 62 return [...(await this._getIdentifyingUrlSegmentsAsync()), NGROK_CONFIG.domain].join('.'); 63 } 64 65 /** Exposed for testing. */ 66 async _getProjectSubdomainAsync(): Promise<string> { 67 return (await this._getIdentifyingUrlSegmentsAsync()).join('-'); 68 } 69 70 /** Start ngrok on the given port for the project. */ 71 async startAsync({ timeout }: { timeout?: number } = {}): Promise<void> { 72 // Ensure the instance is loaded first, this can linger so we should run it before the timeout. 73 await this.resolver.resolveAsync({ 74 // For now, prefer global install since the package has native code (harder to install) and doesn't change very often. 75 prefersGlobalInstall: true, 76 }); 77 78 // NOTE(EvanBacon): If the user doesn't have ADB installed, 79 // then skip attempting to reverse the port. 80 if (hasAdbReverseAsync()) { 81 // Ensure ADB reverse is running. 82 if (!(await startAdbReverseAsync([this.port]))) { 83 // TODO: Better error message. 84 throw new CommandError( 85 'NGROK_ADB', 86 `Cannot start tunnel URL because \`adb reverse\` failed for the connected Android device(s).` 87 ); 88 } 89 } 90 91 this.serverUrl = await this._connectToNgrokAsync({ timeout }); 92 93 debug('Tunnel URL:', this.serverUrl); 94 Log.log('Tunnel ready.'); 95 } 96 97 /** Stop the ngrok process if it's running. */ 98 public async stopAsync(): Promise<void> { 99 debug('Stopping Tunnel'); 100 101 await this.resolver.get()?.kill?.(); 102 this.serverUrl = null; 103 } 104 105 /** Exposed for testing. */ 106 async _connectToNgrokAsync( 107 options: { timeout?: number } = {}, 108 attempts: number = 0 109 ): Promise<string> { 110 // Attempt to stop any hanging processes, this increases the chances of a successful connection. 111 await this.stopAsync(); 112 113 // Get the instance quietly or assert otherwise. 114 const instance = await this.resolver.resolveAsync({ 115 shouldPrompt: false, 116 autoInstall: false, 117 }); 118 119 // TODO(Bacon): Consider dropping the timeout functionality: 120 // https://github.com/expo/expo/pull/16556#discussion_r822307373 121 const results = await resolveWithTimeout( 122 () => this.connectToNgrokInternalAsync(instance, attempts), 123 { 124 timeout: options.timeout ?? TUNNEL_TIMEOUT, 125 errorMessage: 'ngrok tunnel took too long to connect.', 126 } 127 ); 128 if (typeof results === 'string') { 129 return results; 130 } 131 132 // Wait 100ms and then try again 133 await delayAsync(100); 134 135 return this._connectToNgrokAsync(options, attempts + 1); 136 } 137 138 private async _getConnectionPropsAsync(): Promise<{ hostname?: string; subdomain?: string }> { 139 const userDefinedSubdomain = env.EXPO_TUNNEL_SUBDOMAIN; 140 if (userDefinedSubdomain) { 141 const subdomain = 142 typeof userDefinedSubdomain === 'string' 143 ? userDefinedSubdomain 144 : await this._getProjectSubdomainAsync(); 145 debug('Subdomain:', subdomain); 146 return { subdomain }; 147 } else { 148 const hostname = await this._getProjectHostnameAsync(); 149 debug('Hostname:', hostname); 150 return { hostname }; 151 } 152 } 153 154 private async connectToNgrokInternalAsync( 155 instance: NgrokInstance, 156 attempts: number = 0 157 ): Promise<string | false> { 158 try { 159 // Global config path. 160 const configPath = path.join(UserSettings.getDirectory(), 'ngrok.yml'); 161 debug('Global config path:', configPath); 162 const urlProps = await this._getConnectionPropsAsync(); 163 164 const url = await instance.connect({ 165 ...urlProps, 166 authtoken: NGROK_CONFIG.authToken, 167 proto: 'http', 168 configPath, 169 onStatusChange(status) { 170 if (status === 'closed') { 171 Log.error( 172 chalk.red( 173 'Tunnel connection has been closed. This is often related to intermittent connection problems with the Ngrok servers. Restart the dev server to try connecting to Ngrok again.' 174 ) + chalk.gray('\nCheck the Ngrok status page for outages: https://status.ngrok.com/') 175 ); 176 } else if (status === 'connected') { 177 Log.log('Tunnel connected.'); 178 } 179 }, 180 port: this.port, 181 }); 182 return url; 183 } catch (error: any) { 184 const assertNgrok = () => { 185 if (isNgrokClientError(error)) { 186 throw new CommandError( 187 'NGROK_CONNECT', 188 [ 189 error.body.msg, 190 error.body.details?.err, 191 chalk.gray('Check the Ngrok status page for outages: https://status.ngrok.com/'), 192 ] 193 .filter(Boolean) 194 .join('\n\n') 195 ); 196 } 197 throw new CommandError( 198 'NGROK_CONNECT', 199 error.toString() + 200 chalk.gray('\nCheck the Ngrok status page for outages: https://status.ngrok.com/') 201 ); 202 }; 203 204 // Attempt to connect 3 times 205 if (attempts >= 2) { 206 assertNgrok(); 207 } 208 209 // Attempt to fix the issue 210 if (isNgrokClientError(error) && error.body.error_code === 103) { 211 // Assert early if a custom subdomain is used since it cannot 212 // be changed and retried. If the tunnel subdomain is a boolean 213 // then we can reset the randomness and try again. 214 if (typeof env.EXPO_TUNNEL_SUBDOMAIN === 'string') { 215 assertNgrok(); 216 } 217 // Change randomness to avoid conflict if killing ngrok doesn't help 218 await this._resetProjectRandomnessAsync(); 219 } 220 221 return false; 222 } 223 } 224 225 private async getProjectRandomnessAsync() { 226 const { urlRandomness: randomness } = await ProjectSettings.readAsync(this.projectRoot); 227 if (randomness) { 228 return randomness; 229 } 230 return await this._resetProjectRandomnessAsync(); 231 } 232 233 async _resetProjectRandomnessAsync() { 234 const randomness = crypto.randomBytes(5).toString('base64url'); 235 await ProjectSettings.setAsync(this.projectRoot, { urlRandomness: randomness }); 236 debug('Resetting project randomness:', randomness); 237 return randomness; 238 } 239} 240