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