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 { 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 // Ensure ADB reverse is running. 75 if (!(await startAdbReverseAsync([this.port]))) { 76 // TODO: Better error message. 77 throw new CommandError( 78 'NGROK_ADB', 79 `Cannot start tunnel URL because \`adb reverse\` failed for the connected Android device(s).` 80 ); 81 } 82 83 this.serverUrl = await this._connectToNgrokAsync({ timeout }); 84 85 debug('Tunnel URL:', this.serverUrl); 86 Log.log('Tunnel ready.'); 87 } 88 89 /** Stop the ngrok process if it's running. */ 90 public async stopAsync(): Promise<void> { 91 debug('Stopping Tunnel'); 92 93 await this.resolver.get()?.kill?.(); 94 this.serverUrl = null; 95 } 96 97 /** Exposed for testing. */ 98 async _connectToNgrokAsync( 99 options: { timeout?: number } = {}, 100 attempts: number = 0 101 ): Promise<string> { 102 // Attempt to stop any hanging processes, this increases the chances of a successful connection. 103 await this.stopAsync(); 104 105 // Get the instance quietly or assert otherwise. 106 const instance = await this.resolver.resolveAsync({ 107 shouldPrompt: false, 108 autoInstall: false, 109 }); 110 111 // TODO(Bacon): Consider dropping the timeout functionality: 112 // https://github.com/expo/expo/pull/16556#discussion_r822307373 113 const results = await resolveWithTimeout( 114 () => this.connectToNgrokInternalAsync(instance, attempts), 115 { 116 timeout: options.timeout ?? TUNNEL_TIMEOUT, 117 errorMessage: 'ngrok tunnel took too long to connect.', 118 } 119 ); 120 if (typeof results === 'string') { 121 return results; 122 } 123 124 // Wait 100ms and then try again 125 await delayAsync(100); 126 127 return this._connectToNgrokAsync(options, attempts + 1); 128 } 129 130 private async _getConnectionPropsAsync(): Promise<{ hostname?: string; subdomain?: string }> { 131 const userDefinedSubdomain = env.EXPO_TUNNEL_SUBDOMAIN; 132 if (userDefinedSubdomain) { 133 const subdomain = 134 typeof userDefinedSubdomain === 'string' 135 ? userDefinedSubdomain 136 : await this._getProjectSubdomainAsync(); 137 debug('Subdomain:', subdomain); 138 return { subdomain }; 139 } else { 140 const hostname = await this._getProjectHostnameAsync(); 141 debug('Hostname:', hostname); 142 return { hostname }; 143 } 144 } 145 146 private async connectToNgrokInternalAsync( 147 instance: NgrokInstance, 148 attempts: number = 0 149 ): Promise<string | false> { 150 try { 151 // Global config path. 152 const configPath = path.join(UserSettings.getDirectory(), 'ngrok.yml'); 153 debug('Global config path:', configPath); 154 const urlProps = await this._getConnectionPropsAsync(); 155 156 const url = await instance.connect({ 157 ...urlProps, 158 authtoken: NGROK_CONFIG.authToken, 159 proto: 'http', 160 configPath, 161 onStatusChange(status) { 162 if (status === 'closed') { 163 Log.error( 164 'We noticed your tunnel is having issues. ' + 165 'This may be due to intermittent problems with ngrok. ' + 166 'If you have trouble connecting to your app, try to restart the project, ' + 167 'or switch the host to `lan`.' 168 ); 169 } else if (status === 'connected') { 170 Log.log('Tunnel connected.'); 171 } 172 }, 173 port: this.port, 174 }); 175 return url; 176 } catch (error: any) { 177 const assertNgrok = () => { 178 if (isNgrokClientError(error)) { 179 throw new CommandError( 180 'NGROK_CONNECT', 181 [error.body.msg, error.body.details?.err].filter(Boolean).join('\n\n') 182 ); 183 } 184 throw new CommandError('NGROK_CONNECT', error.toString()); 185 }; 186 187 // Attempt to connect 3 times 188 if (attempts >= 2) { 189 assertNgrok(); 190 } 191 192 // Attempt to fix the issue 193 if (isNgrokClientError(error) && error.body.error_code === 103) { 194 // Assert early if a custom subdomain is used since it cannot 195 // be changed and retried. If the tunnel subdomain is a boolean 196 // then we can reset the randomness and try again. 197 if (typeof env.EXPO_TUNNEL_SUBDOMAIN === 'string') { 198 assertNgrok(); 199 } 200 // Change randomness to avoid conflict if killing ngrok doesn't help 201 await this._resetProjectRandomnessAsync(); 202 } 203 204 return false; 205 } 206 } 207 208 private async getProjectRandomnessAsync() { 209 const { urlRandomness: randomness } = await ProjectSettings.readAsync(this.projectRoot); 210 if (randomness) { 211 return randomness; 212 } 213 return await this._resetProjectRandomnessAsync(); 214 } 215 216 async _resetProjectRandomnessAsync() { 217 const randomness = crypto.randomBytes(5).toString('base64url'); 218 await ProjectSettings.setAsync(this.projectRoot, { urlRandomness: randomness }); 219 debug('Resetting project randomness:', randomness); 220 return randomness; 221 } 222} 223