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 ) 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 [error.body.msg, error.body.details?.err].filter(Boolean).join('\n\n') 189 ); 190 } 191 throw new CommandError('NGROK_CONNECT', error.toString()); 192 }; 193 194 // Attempt to connect 3 times 195 if (attempts >= 2) { 196 assertNgrok(); 197 } 198 199 // Attempt to fix the issue 200 if (isNgrokClientError(error) && error.body.error_code === 103) { 201 // Assert early if a custom subdomain is used since it cannot 202 // be changed and retried. If the tunnel subdomain is a boolean 203 // then we can reset the randomness and try again. 204 if (typeof env.EXPO_TUNNEL_SUBDOMAIN === 'string') { 205 assertNgrok(); 206 } 207 // Change randomness to avoid conflict if killing ngrok doesn't help 208 await this._resetProjectRandomnessAsync(); 209 } 210 211 return false; 212 } 213 } 214 215 private async getProjectRandomnessAsync() { 216 const { urlRandomness: randomness } = await ProjectSettings.readAsync(this.projectRoot); 217 if (randomness) { 218 return randomness; 219 } 220 return await this._resetProjectRandomnessAsync(); 221 } 222 223 async _resetProjectRandomnessAsync() { 224 const randomness = crypto.randomBytes(5).toString('base64url'); 225 await ProjectSettings.setAsync(this.projectRoot, { urlRandomness: randomness }); 226 debug('Resetting project randomness:', randomness); 227 return randomness; 228 } 229} 230