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