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