1import assert from 'assert';
2import { URL } from 'url';
3
4import * as Log from '../../log';
5import { env } from '../../utils/env';
6import { getIpAddress } from '../../utils/ip';
7
8const debug = require('debug')('expo:start:server:urlCreator') as typeof console.log;
9
10export interface CreateURLOptions {
11  /** URL scheme to use when opening apps in custom runtimes. */
12  scheme?: string | null;
13  /** Type of dev server host to use. */
14  hostType?: 'localhost' | 'lan' | 'tunnel';
15  /** Requested hostname. */
16  hostname?: string | null;
17}
18
19interface UrlComponents {
20  port: string;
21  hostname: string;
22  protocol: string;
23}
24export class UrlCreator {
25  constructor(
26    private defaults: CreateURLOptions | undefined,
27    private bundlerInfo: { port: number; getTunnelUrl?: () => string | null }
28  ) {}
29
30  /**
31   * Return a URL for the "loading" interstitial page that is used to disambiguate which
32   * native runtime to open the dev server with.
33   *
34   * @param options options for creating the URL
35   * @param platform when opening the URL from the CLI to a connected device we can specify the platform as a query parameter, otherwise it will be inferred from the unsafe user agent sniffing.
36   *
37   * @returns URL like `http://localhost:19000/_expo/loading?platform=ios`
38   * @returns URL like `http://localhost:19000/_expo/loading` when no platform is provided.
39   */
40  public constructLoadingUrl(options: CreateURLOptions, platform: string | null): string {
41    const url = new URL('_expo/loading', this.constructUrl({ scheme: 'http', ...options }));
42    if (platform) {
43      url.search = new URLSearchParams({ platform }).toString();
44    }
45    const loadingUrl = url.toString();
46    debug(`Loading URL: ${loadingUrl}`);
47    return loadingUrl;
48  }
49
50  /** Create a URI for launching in a native dev client. Returns `null` when no `scheme` can be resolved. */
51  public constructDevClientUrl(options?: CreateURLOptions): null | string {
52    const protocol = options?.scheme || this.defaults?.scheme;
53
54    if (
55      !protocol ||
56      // Prohibit the use of http(s) in dev client URIs since they'll never be valid.
57      ['http', 'https'].includes(protocol.toLowerCase())
58    ) {
59      return null;
60    }
61
62    const manifestUrl = this.constructUrl({ ...options, scheme: 'http' });
63    const devClientUrl = `${protocol}://expo-development-client/?url=${encodeURIComponent(
64      manifestUrl
65    )}`;
66    debug(`Dev client URL: ${devClientUrl} -- manifestUrl: ${manifestUrl} -- %O`, options);
67    return devClientUrl;
68  }
69
70  /** Create a generic URL. */
71  public constructUrl(options?: Partial<CreateURLOptions> | null): string {
72    const urlComponents = this.getUrlComponents({
73      ...this.defaults,
74      ...options,
75    });
76    const url = joinUrlComponents(urlComponents);
77    debug(`URL: ${url}`);
78    return url;
79  }
80
81  /** Get the URL components from the Ngrok server URL. */
82  private getTunnelUrlComponents(options: Pick<CreateURLOptions, 'scheme'>): UrlComponents | null {
83    const tunnelUrl = this.bundlerInfo.getTunnelUrl?.();
84    if (!tunnelUrl) {
85      return null;
86    }
87    const parsed = new URL(tunnelUrl);
88    return {
89      port: parsed.port,
90      hostname: parsed.hostname,
91      protocol: options.scheme ?? 'http',
92    };
93  }
94
95  private getUrlComponents(options: CreateURLOptions): UrlComponents {
96    // Proxy comes first.
97    const proxyURL = getProxyUrl();
98    if (proxyURL) {
99      return getUrlComponentsFromProxyUrl(options, proxyURL);
100    }
101
102    // Ngrok.
103    if (options.hostType === 'tunnel') {
104      const components = this.getTunnelUrlComponents(options);
105      if (components) {
106        return components;
107      }
108      Log.warn('Tunnel URL not found (it might not be ready yet), falling back to LAN URL.');
109    } else if (options.hostType === 'localhost' && !options.hostname) {
110      options.hostname = 'localhost';
111    }
112
113    return {
114      hostname: getDefaultHostname(options),
115      port: this.bundlerInfo.port.toString(),
116      protocol: options.scheme ?? 'http',
117    };
118  }
119}
120
121function getUrlComponentsFromProxyUrl(
122  options: Pick<CreateURLOptions, 'scheme'>,
123  url: string
124): UrlComponents {
125  const parsedProxyUrl = new URL(url);
126  let protocol = options.scheme ?? 'http';
127  if (parsedProxyUrl.protocol === 'https:') {
128    if (protocol === 'http') {
129      protocol = 'https';
130    }
131    if (!parsedProxyUrl.port) {
132      parsedProxyUrl.port = '443';
133    }
134  }
135  return {
136    port: parsedProxyUrl.port,
137    hostname: parsedProxyUrl.hostname,
138    protocol,
139  };
140}
141
142function getDefaultHostname(options: Pick<CreateURLOptions, 'hostname'>) {
143  // TODO: Drop REACT_NATIVE_PACKAGER_HOSTNAME
144  if (process.env.REACT_NATIVE_PACKAGER_HOSTNAME) {
145    return process.env.REACT_NATIVE_PACKAGER_HOSTNAME.trim();
146  } else if (options.hostname === 'localhost') {
147    // Restrict the use of `localhost`
148    // TODO: Note why we do this.
149    return '127.0.0.1';
150  }
151
152  return options.hostname || getIpAddress();
153}
154
155function joinUrlComponents({ protocol, hostname, port }: Partial<UrlComponents>): string {
156  assert(hostname, 'hostname cannot be inferred.');
157  // Android HMR breaks without this port 80.
158  // This is because Android React Native WebSocket implementation is not spec compliant and fails without a port:
159  // `E unknown:ReactNative: java.lang.IllegalArgumentException: Invalid URL port: "-1"`
160  // Invoked first in `metro-runtime/src/modules/HMRClient.js`
161  const validPort = env.EXPO_NO_DEFAULT_PORT ? port : port || '80';
162  const validProtocol = protocol ? `${protocol}://` : '';
163
164  let url = `${validProtocol}${hostname}`;
165
166  if (validPort) {
167    url += `:${validPort}`;
168  }
169
170  return url;
171}
172
173/** @deprecated */
174function getProxyUrl(): string | undefined {
175  return process.env.EXPO_PACKAGER_PROXY_URL;
176}
177
178// TODO: Drop the undocumented env variables:
179// REACT_NATIVE_PACKAGER_HOSTNAME
180// EXPO_PACKAGER_PROXY_URL
181