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