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