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 { hasAdbReverseAsync, 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    // NOTE(EvanBacon): If the user doesn't have ADB installed,
75    // then skip attempting to reverse the port.
76    if (hasAdbReverseAsync()) {
77      // Ensure ADB reverse is running.
78      if (!(await startAdbReverseAsync([this.port]))) {
79        // TODO: Better error message.
80        throw new CommandError(
81          'NGROK_ADB',
82          `Cannot start tunnel URL because \`adb reverse\` failed for the connected Android device(s).`
83        );
84      }
85    }
86
87    this.serverUrl = await this._connectToNgrokAsync({ timeout });
88
89    debug('Tunnel URL:', this.serverUrl);
90    Log.log('Tunnel ready.');
91  }
92
93  /** Stop the ngrok process if it's running. */
94  public async stopAsync(): Promise<void> {
95    debug('Stopping Tunnel');
96
97    await this.resolver.get()?.kill?.();
98    this.serverUrl = null;
99  }
100
101  /** Exposed for testing. */
102  async _connectToNgrokAsync(
103    options: { timeout?: number } = {},
104    attempts: number = 0
105  ): Promise<string> {
106    // Attempt to stop any hanging processes, this increases the chances of a successful connection.
107    await this.stopAsync();
108
109    // Get the instance quietly or assert otherwise.
110    const instance = await this.resolver.resolveAsync({
111      shouldPrompt: false,
112      autoInstall: false,
113    });
114
115    // TODO(Bacon): Consider dropping the timeout functionality:
116    // https://github.com/expo/expo/pull/16556#discussion_r822307373
117    const results = await resolveWithTimeout(
118      () => this.connectToNgrokInternalAsync(instance, attempts),
119      {
120        timeout: options.timeout ?? TUNNEL_TIMEOUT,
121        errorMessage: 'ngrok tunnel took too long to connect.',
122      }
123    );
124    if (typeof results === 'string') {
125      return results;
126    }
127
128    // Wait 100ms and then try again
129    await delayAsync(100);
130
131    return this._connectToNgrokAsync(options, attempts + 1);
132  }
133
134  private async _getConnectionPropsAsync(): Promise<{ hostname?: string; subdomain?: string }> {
135    const userDefinedSubdomain = env.EXPO_TUNNEL_SUBDOMAIN;
136    if (userDefinedSubdomain) {
137      const subdomain =
138        typeof userDefinedSubdomain === 'string'
139          ? userDefinedSubdomain
140          : await this._getProjectSubdomainAsync();
141      debug('Subdomain:', subdomain);
142      return { subdomain };
143    } else {
144      const hostname = await this._getProjectHostnameAsync();
145      debug('Hostname:', hostname);
146      return { hostname };
147    }
148  }
149
150  private async connectToNgrokInternalAsync(
151    instance: NgrokInstance,
152    attempts: number = 0
153  ): Promise<string | false> {
154    try {
155      // Global config path.
156      const configPath = path.join(UserSettings.getDirectory(), 'ngrok.yml');
157      debug('Global config path:', configPath);
158      const urlProps = await this._getConnectionPropsAsync();
159
160      const url = await instance.connect({
161        ...urlProps,
162        authtoken: NGROK_CONFIG.authToken,
163        proto: 'http',
164        configPath,
165        onStatusChange(status) {
166          if (status === 'closed') {
167            Log.error(
168              'We noticed your tunnel is having issues. ' +
169                'This may be due to intermittent problems with ngrok. ' +
170                'If you have trouble connecting to your app, try to restart the project, ' +
171                'or switch the host to `lan`.'
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