1import chalk from 'chalk';
2import crypto from 'crypto';
3import * as path from 'path';
4import slugify from 'slugify';
5
6import UserSettings from '../../api/user/UserSettings';
7import { getActorDisplayName, getUserAsync } from '../../api/user/user';
8import * as Log from '../../log';
9import { delayAsync, resolveWithTimeout } from '../../utils/delay';
10import { env } from '../../utils/env';
11import { CommandError } from '../../utils/errors';
12import { isNgrokClientError, NgrokInstance, NgrokResolver } from '../doctor/ngrok/NgrokResolver';
13import { hasAdbReverseAsync, startAdbReverseAsync } from '../platforms/android/adbReverse';
14import { ProjectSettings } from '../project/settings';
15
16const debug = require('debug')('expo:start:server:ngrok') as typeof console.log;
17
18const NGROK_CONFIG = {
19  authToken: '5W1bR67GNbWcXqmxZzBG1_56GezNeaX6sSRvn8npeQ8',
20  domain: 'exp.direct',
21};
22
23const TUNNEL_TIMEOUT = 10 * 1000;
24
25export class AsyncNgrok {
26  /** Resolves the best instance of ngrok, exposed for testing. */
27  resolver: NgrokResolver;
28
29  /** Info about the currently running instance of ngrok. */
30  private serverUrl: string | null = null;
31
32  constructor(private projectRoot: string, private port: number) {
33    this.resolver = new NgrokResolver(projectRoot);
34  }
35
36  public getActiveUrl(): string | null {
37    return this.serverUrl;
38  }
39
40  /** Exposed for testing. */
41  async _getIdentifyingUrlSegmentsAsync(): Promise<string[]> {
42    const user = await getUserAsync();
43    if (user?.__typename === 'Robot') {
44      throw new CommandError('NGROK_ROBOT', 'Cannot use ngrok with a robot user.');
45    }
46    const username = getActorDisplayName(user);
47
48    return [
49      // NOTE: https://github.com/expo/expo/pull/16556#discussion_r822944286
50      await this.getProjectRandomnessAsync(),
51      slugify(username),
52      // Use the port to distinguish between multiple tunnels (webpack, metro).
53      String(this.port),
54    ];
55  }
56
57  /** Exposed for testing. */
58  async _getProjectHostnameAsync(): Promise<string> {
59    return [...(await this._getIdentifyingUrlSegmentsAsync()), NGROK_CONFIG.domain].join('.');
60  }
61
62  /** Exposed for testing. */
63  async _getProjectSubdomainAsync(): Promise<string> {
64    return (await this._getIdentifyingUrlSegmentsAsync()).join('-');
65  }
66
67  /** Start ngrok on the given port for the project. */
68  async startAsync({ timeout }: { timeout?: number } = {}): Promise<void> {
69    // Ensure the instance is loaded first, this can linger so we should run it before the timeout.
70    await this.resolver.resolveAsync({
71      // For now, prefer global install since the package has native code (harder to install) and doesn't change very often.
72      prefersGlobalInstall: true,
73    });
74
75    // NOTE(EvanBacon): If the user doesn't have ADB installed,
76    // then skip attempting to reverse the port.
77    if (hasAdbReverseAsync()) {
78      // Ensure ADB reverse is running.
79      if (!(await startAdbReverseAsync([this.port]))) {
80        // TODO: Better error message.
81        throw new CommandError(
82          'NGROK_ADB',
83          `Cannot start tunnel URL because \`adb reverse\` failed for the connected Android device(s).`
84        );
85      }
86    }
87
88    this.serverUrl = await this._connectToNgrokAsync({ timeout });
89
90    debug('Tunnel URL:', this.serverUrl);
91    Log.log('Tunnel ready.');
92  }
93
94  /** Stop the ngrok process if it's running. */
95  public async stopAsync(): Promise<void> {
96    debug('Stopping Tunnel');
97
98    await this.resolver.get()?.kill?.();
99    this.serverUrl = null;
100  }
101
102  /** Exposed for testing. */
103  async _connectToNgrokAsync(
104    options: { timeout?: number } = {},
105    attempts: number = 0
106  ): Promise<string> {
107    // Attempt to stop any hanging processes, this increases the chances of a successful connection.
108    await this.stopAsync();
109
110    // Get the instance quietly or assert otherwise.
111    const instance = await this.resolver.resolveAsync({
112      shouldPrompt: false,
113      autoInstall: false,
114    });
115
116    // TODO(Bacon): Consider dropping the timeout functionality:
117    // https://github.com/expo/expo/pull/16556#discussion_r822307373
118    const results = await resolveWithTimeout(
119      () => this.connectToNgrokInternalAsync(instance, attempts),
120      {
121        timeout: options.timeout ?? TUNNEL_TIMEOUT,
122        errorMessage: 'ngrok tunnel took too long to connect.',
123      }
124    );
125    if (typeof results === 'string') {
126      return results;
127    }
128
129    // Wait 100ms and then try again
130    await delayAsync(100);
131
132    return this._connectToNgrokAsync(options, attempts + 1);
133  }
134
135  private async _getConnectionPropsAsync(): Promise<{ hostname?: string; subdomain?: string }> {
136    const userDefinedSubdomain = env.EXPO_TUNNEL_SUBDOMAIN;
137    if (userDefinedSubdomain) {
138      const subdomain =
139        typeof userDefinedSubdomain === 'string'
140          ? userDefinedSubdomain
141          : await this._getProjectSubdomainAsync();
142      debug('Subdomain:', subdomain);
143      return { subdomain };
144    } else {
145      const hostname = await this._getProjectHostnameAsync();
146      debug('Hostname:', hostname);
147      return { hostname };
148    }
149  }
150
151  private async connectToNgrokInternalAsync(
152    instance: NgrokInstance,
153    attempts: number = 0
154  ): Promise<string | false> {
155    try {
156      // Global config path.
157      const configPath = path.join(UserSettings.getDirectory(), 'ngrok.yml');
158      debug('Global config path:', configPath);
159      const urlProps = await this._getConnectionPropsAsync();
160
161      const url = await instance.connect({
162        ...urlProps,
163        authtoken: NGROK_CONFIG.authToken,
164        proto: 'http',
165        configPath,
166        onStatusChange(status) {
167          if (status === 'closed') {
168            Log.error(
169              chalk.red(
170                'Tunnel connection has been closed. This is often related to intermittent connection problems with the Ngrok servers. Restart the dev server to try connecting to Ngrok again.'
171              )
172            );
173          } else if (status === 'connected') {
174            Log.log('Tunnel connected.');
175          }
176        },
177        port: this.port,
178      });
179      return url;
180    } catch (error: any) {
181      const assertNgrok = () => {
182        if (isNgrokClientError(error)) {
183          throw new CommandError(
184            'NGROK_CONNECT',
185            [error.body.msg, error.body.details?.err].filter(Boolean).join('\n\n')
186          );
187        }
188        throw new CommandError('NGROK_CONNECT', error.toString());
189      };
190
191      // Attempt to connect 3 times
192      if (attempts >= 2) {
193        assertNgrok();
194      }
195
196      // Attempt to fix the issue
197      if (isNgrokClientError(error) && error.body.error_code === 103) {
198        // Assert early if a custom subdomain is used since it cannot
199        // be changed and retried. If the tunnel subdomain is a boolean
200        // then we can reset the randomness and try again.
201        if (typeof env.EXPO_TUNNEL_SUBDOMAIN === 'string') {
202          assertNgrok();
203        }
204        // Change randomness to avoid conflict if killing ngrok doesn't help
205        await this._resetProjectRandomnessAsync();
206      }
207
208      return false;
209    }
210  }
211
212  private async getProjectRandomnessAsync() {
213    const { urlRandomness: randomness } = await ProjectSettings.readAsync(this.projectRoot);
214    if (randomness) {
215      return randomness;
216    }
217    return await this._resetProjectRandomnessAsync();
218  }
219
220  async _resetProjectRandomnessAsync() {
221    const randomness = crypto.randomBytes(5).toString('base64url');
222    await ProjectSettings.setAsync(this.projectRoot, { urlRandomness: randomness });
223    debug('Resetting project randomness:', randomness);
224    return randomness;
225  }
226}
227