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