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