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