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