1import crypto from 'crypto'; 2import * as path from 'path'; 3import slugify from 'slugify'; 4 5import UserSettings from '../../api/user/UserSettings'; 6import { getActorDisplayName, getUserAsync } from '../../api/user/user'; 7import * as Log from '../../log'; 8import { delayAsync, resolveWithTimeout } from '../../utils/delay'; 9import { env } from '../../utils/env'; 10import { CommandError } from '../../utils/errors'; 11import { isNgrokClientError, NgrokInstance, NgrokResolver } from '../doctor/ngrok/NgrokResolver'; 12import { hasAdbReverseAsync, startAdbReverseAsync } from '../platforms/android/adbReverse'; 13import { ProjectSettings } from '../project/settings'; 14 15const debug = require('debug')('expo:start:server:ngrok') as typeof console.log; 16 17const NGROK_CONFIG = { 18 authToken: '5W1bR67GNbWcXqmxZzBG1_56GezNeaX6sSRvn8npeQ8', 19 domain: 'exp.direct', 20}; 21 22const TUNNEL_TIMEOUT = 10 * 1000; 23 24export class AsyncNgrok { 25 /** Resolves the best instance of ngrok, exposed for testing. */ 26 resolver: NgrokResolver; 27 28 /** Info about the currently running instance of ngrok. */ 29 private serverUrl: string | null = null; 30 31 constructor(private projectRoot: string, private port: number) { 32 this.resolver = new NgrokResolver(projectRoot); 33 } 34 35 public getActiveUrl(): string | null { 36 return this.serverUrl; 37 } 38 39 /** Exposed for testing. */ 40 async _getIdentifyingUrlSegmentsAsync(): Promise<string[]> { 41 const user = await getUserAsync(); 42 if (user?.__typename === 'Robot') { 43 throw new CommandError('NGROK_ROBOT', 'Cannot use ngrok with a robot user.'); 44 } 45 const username = getActorDisplayName(user); 46 47 return [ 48 // NOTE: https://github.com/expo/expo/pull/16556#discussion_r822944286 49 await this.getProjectRandomnessAsync(), 50 slugify(username), 51 // Use the port to distinguish between multiple tunnels (webpack, metro). 52 String(this.port), 53 ]; 54 } 55 56 /** Exposed for testing. */ 57 async _getProjectHostnameAsync(): Promise<string> { 58 return [...(await this._getIdentifyingUrlSegmentsAsync()), NGROK_CONFIG.domain].join('.'); 59 } 60 61 /** Exposed for testing. */ 62 async _getProjectSubdomainAsync(): Promise<string> { 63 return (await this._getIdentifyingUrlSegmentsAsync()).join('-'); 64 } 65 66 /** Start ngrok on the given port for the project. */ 67 async startAsync({ timeout }: { timeout?: number } = {}): Promise<void> { 68 // Ensure the instance is loaded first, this can linger so we should run it before the timeout. 69 await this.resolver.resolveAsync({ 70 // For now, prefer global install since the package has native code (harder to install) and doesn't change very often. 71 prefersGlobalInstall: true, 72 }); 73 74 // NOTE(EvanBacon): If the user doesn't have ADB installed, 75 // then skip attempting to reverse the port. 76 if (hasAdbReverseAsync()) { 77 // Ensure ADB reverse is running. 78 if (!(await startAdbReverseAsync([this.port]))) { 79 // TODO: Better error message. 80 throw new CommandError( 81 'NGROK_ADB', 82 `Cannot start tunnel URL because \`adb reverse\` failed for the connected Android device(s).` 83 ); 84 } 85 } 86 87 this.serverUrl = await this._connectToNgrokAsync({ timeout }); 88 89 debug('Tunnel URL:', this.serverUrl); 90 Log.log('Tunnel ready.'); 91 } 92 93 /** Stop the ngrok process if it's running. */ 94 public async stopAsync(): Promise<void> { 95 debug('Stopping Tunnel'); 96 97 await this.resolver.get()?.kill?.(); 98 this.serverUrl = null; 99 } 100 101 /** Exposed for testing. */ 102 async _connectToNgrokAsync( 103 options: { timeout?: number } = {}, 104 attempts: number = 0 105 ): Promise<string> { 106 // Attempt to stop any hanging processes, this increases the chances of a successful connection. 107 await this.stopAsync(); 108 109 // Get the instance quietly or assert otherwise. 110 const instance = await this.resolver.resolveAsync({ 111 shouldPrompt: false, 112 autoInstall: false, 113 }); 114 115 // TODO(Bacon): Consider dropping the timeout functionality: 116 // https://github.com/expo/expo/pull/16556#discussion_r822307373 117 const results = await resolveWithTimeout( 118 () => this.connectToNgrokInternalAsync(instance, attempts), 119 { 120 timeout: options.timeout ?? TUNNEL_TIMEOUT, 121 errorMessage: 'ngrok tunnel took too long to connect.', 122 } 123 ); 124 if (typeof results === 'string') { 125 return results; 126 } 127 128 // Wait 100ms and then try again 129 await delayAsync(100); 130 131 return this._connectToNgrokAsync(options, attempts + 1); 132 } 133 134 private async _getConnectionPropsAsync(): Promise<{ hostname?: string; subdomain?: string }> { 135 const userDefinedSubdomain = env.EXPO_TUNNEL_SUBDOMAIN; 136 if (userDefinedSubdomain) { 137 const subdomain = 138 typeof userDefinedSubdomain === 'string' 139 ? userDefinedSubdomain 140 : await this._getProjectSubdomainAsync(); 141 debug('Subdomain:', subdomain); 142 return { subdomain }; 143 } else { 144 const hostname = await this._getProjectHostnameAsync(); 145 debug('Hostname:', hostname); 146 return { hostname }; 147 } 148 } 149 150 private async connectToNgrokInternalAsync( 151 instance: NgrokInstance, 152 attempts: number = 0 153 ): Promise<string | false> { 154 try { 155 // Global config path. 156 const configPath = path.join(UserSettings.getDirectory(), 'ngrok.yml'); 157 debug('Global config path:', configPath); 158 const urlProps = await this._getConnectionPropsAsync(); 159 160 const url = await instance.connect({ 161 ...urlProps, 162 authtoken: NGROK_CONFIG.authToken, 163 proto: 'http', 164 configPath, 165 onStatusChange(status) { 166 if (status === 'closed') { 167 Log.error( 168 'We noticed your tunnel is having issues. ' + 169 'This may be due to intermittent problems with ngrok. ' + 170 'If you have trouble connecting to your app, try to restart the project, ' + 171 'or switch the host to `lan`.' 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