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   * @returns URL like `http://localhost:19000/_expo/loading?platform=ios`
32   */
33  public constructLoadingUrl(options: CreateURLOptions, platform: string): string {
34    const url = new URL('_expo/loading', this.constructUrl({ scheme: 'http', ...options }));
35    url.search = new URLSearchParams({ platform }).toString();
36    const loadingUrl = url.toString();
37    debug(`Loading URL: ${loadingUrl}`);
38    return loadingUrl;
39  }
40
41  /** Create a URI for launching in a native dev client. Returns `null` when no `scheme` can be resolved. */
42  public constructDevClientUrl(options?: CreateURLOptions): null | string {
43    const protocol = options?.scheme || this.defaults?.scheme;
44
45    if (
46      !protocol ||
47      // Prohibit the use of http(s) in dev client URIs since they'll never be valid.
48      ['http', 'https'].includes(protocol.toLowerCase())
49    ) {
50      return null;
51    }
52
53    const manifestUrl = this.constructUrl({ ...options, scheme: 'http' });
54    const devClientUrl = `${protocol}://expo-development-client/?url=${encodeURIComponent(
55      manifestUrl
56    )}`;
57    debug(`Dev client URL: ${devClientUrl} -- manifestUrl: ${manifestUrl} -- %O`, options);
58    return devClientUrl;
59  }
60
61  /** Create a generic URL. */
62  public constructUrl(options?: Partial<CreateURLOptions> | null): string {
63    const urlComponents = this.getUrlComponents({
64      ...this.defaults,
65      ...options,
66    });
67    const url = joinUrlComponents(urlComponents);
68    debug(`URL: ${url}`);
69    return url;
70  }
71
72  /** Get the URL components from the Ngrok server URL. */
73  private getTunnelUrlComponents(options: Pick<CreateURLOptions, 'scheme'>): UrlComponents | null {
74    const tunnelUrl = this.bundlerInfo.getTunnelUrl?.();
75    if (!tunnelUrl) {
76      return null;
77    }
78    const parsed = new URL(tunnelUrl);
79    return {
80      port: parsed.port,
81      hostname: parsed.hostname,
82      protocol: options.scheme ?? 'http',
83    };
84  }
85
86  private getUrlComponents(options: CreateURLOptions): UrlComponents {
87    // Proxy comes first.
88    const proxyURL = getProxyUrl();
89    if (proxyURL) {
90      return getUrlComponentsFromProxyUrl(options, proxyURL);
91    }
92
93    // Ngrok.
94    if (options.hostType === 'tunnel') {
95      const components = this.getTunnelUrlComponents(options);
96      if (components) {
97        return components;
98      }
99      Log.warn('Tunnel URL not found (it might not be ready yet), falling back to LAN URL.');
100    } else if (options.hostType === 'localhost' && !options.hostname) {
101      options.hostname = 'localhost';
102    }
103
104    return {
105      hostname: getDefaultHostname(options),
106      port: this.bundlerInfo.port.toString(),
107      protocol: options.scheme ?? 'http',
108    };
109  }
110}
111
112function getUrlComponentsFromProxyUrl(
113  options: Pick<CreateURLOptions, 'scheme'>,
114  url: string
115): UrlComponents {
116  const parsedProxyUrl = new URL(url);
117  let protocol = options.scheme ?? 'http';
118  if (parsedProxyUrl.protocol === 'https:') {
119    if (protocol === 'http') {
120      protocol = 'https';
121    }
122    if (!parsedProxyUrl.port) {
123      parsedProxyUrl.port = '443';
124    }
125  }
126  return {
127    port: parsedProxyUrl.port,
128    hostname: parsedProxyUrl.hostname,
129    protocol,
130  };
131}
132
133function getDefaultHostname(options: Pick<CreateURLOptions, 'hostname'>) {
134  // TODO: Drop REACT_NATIVE_PACKAGER_HOSTNAME
135  if (process.env.REACT_NATIVE_PACKAGER_HOSTNAME) {
136    return process.env.REACT_NATIVE_PACKAGER_HOSTNAME.trim();
137  } else if (options.hostname === 'localhost') {
138    // Restrict the use of `localhost`
139    // TODO: Note why we do this.
140    return '127.0.0.1';
141  }
142
143  return options.hostname || getIpAddress();
144}
145
146function joinUrlComponents({ protocol, hostname, port }: Partial<UrlComponents>): string {
147  assert(hostname, 'hostname cannot be inferred.');
148  // Android HMR breaks without this port 80.
149  // This is because Android React Native WebSocket implementation is not spec compliant and fails without a port:
150  // `E unknown:ReactNative: java.lang.IllegalArgumentException: Invalid URL port: "-1"`
151  // Invoked first in `metro-runtime/src/modules/HMRClient.js`
152  const validPort = env.EXPO_NO_DEFAULT_PORT ? port : port || '80';
153  const validProtocol = protocol ? `${protocol}://` : '';
154
155  let url = `${validProtocol}${hostname}`;
156
157  if (validPort) {
158    url += `:${validPort}`;
159  }
160
161  return url;
162}
163
164/** @deprecated */
165function getProxyUrl(): string | undefined {
166  return process.env.EXPO_PACKAGER_PROXY_URL;
167}
168
169// TODO: Drop the undocumented env variables:
170// REACT_NATIVE_PACKAGER_HOSTNAME
171// EXPO_PACKAGER_PROXY_URL
172