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