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    public 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:8081/_expo/loading?platform=ios`
37   * @returns URL like `http://localhost:8081/_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      // Prohibit the use of `_` characters in the protocol, Node will throw an error when parsing these URLs
58      protocol.includes('_')
59    ) {
60      return null;
61    }
62
63    const manifestUrl = this.constructUrl({ ...options, scheme: 'http' });
64    const devClientUrl = `${protocol}://expo-development-client/?url=${encodeURIComponent(
65      manifestUrl
66    )}`;
67    debug(`Dev client URL: ${devClientUrl} -- manifestUrl: ${manifestUrl} -- %O`, options);
68    return devClientUrl;
69  }
70
71  /** Create a generic URL. */
72  public constructUrl(options?: Partial<CreateURLOptions> | null): string {
73    const urlComponents = this.getUrlComponents({
74      ...this.defaults,
75      ...options,
76    });
77    const url = joinUrlComponents(urlComponents);
78    debug(`URL: ${url}`);
79    return url;
80  }
81
82  /** Get the URL components from the Ngrok server URL. */
83  private getTunnelUrlComponents(options: Pick<CreateURLOptions, 'scheme'>): UrlComponents | null {
84    const tunnelUrl = this.bundlerInfo.getTunnelUrl?.();
85    if (!tunnelUrl) {
86      return null;
87    }
88    const parsed = new URL(tunnelUrl);
89    return {
90      port: parsed.port,
91      hostname: parsed.hostname,
92      protocol: options.scheme ?? 'http',
93    };
94  }
95
96  private getUrlComponents(options: CreateURLOptions): UrlComponents {
97    // Proxy comes first.
98    const proxyURL = getProxyUrl();
99    if (proxyURL) {
100      return getUrlComponentsFromProxyUrl(options, proxyURL);
101    }
102
103    // Ngrok.
104    if (options.hostType === 'tunnel') {
105      const components = this.getTunnelUrlComponents(options);
106      if (components) {
107        return components;
108      }
109      Log.warn('Tunnel URL not found (it might not be ready yet), falling back to LAN URL.');
110    } else if (options.hostType === 'localhost' && !options.hostname) {
111      options.hostname = 'localhost';
112    }
113
114    return {
115      hostname: getDefaultHostname(options),
116      port: this.bundlerInfo.port.toString(),
117      protocol: options.scheme ?? 'http',
118    };
119  }
120}
121
122function getUrlComponentsFromProxyUrl(
123  options: Pick<CreateURLOptions, 'scheme'>,
124  url: string
125): UrlComponents {
126  const parsedProxyUrl = new URL(url);
127  let protocol = options.scheme ?? 'http';
128  if (parsedProxyUrl.protocol === 'https:') {
129    if (protocol === 'http') {
130      protocol = 'https';
131    }
132    if (!parsedProxyUrl.port) {
133      parsedProxyUrl.port = '443';
134    }
135  }
136  return {
137    port: parsedProxyUrl.port,
138    hostname: parsedProxyUrl.hostname,
139    protocol,
140  };
141}
142
143function getDefaultHostname(options: Pick<CreateURLOptions, 'hostname'>) {
144  // TODO: Drop REACT_NATIVE_PACKAGER_HOSTNAME
145  if (process.env.REACT_NATIVE_PACKAGER_HOSTNAME) {
146    return process.env.REACT_NATIVE_PACKAGER_HOSTNAME.trim();
147  } else if (options.hostname === 'localhost') {
148    // Restrict the use of `localhost`
149    // TODO: Note why we do this.
150    return '127.0.0.1';
151  }
152
153  return options.hostname || getIpAddress();
154}
155
156function joinUrlComponents({ protocol, hostname, port }: Partial<UrlComponents>): string {
157  assert(hostname, 'hostname cannot be inferred.');
158  const validProtocol = protocol ? `${protocol}://` : '';
159
160  const url = `${validProtocol}${hostname}`;
161
162  if (port) {
163    return url + `:${port}`;
164  }
165
166  return url;
167}
168
169/** @deprecated */
170function getProxyUrl(): string | undefined {
171  return process.env.EXPO_PACKAGER_PROXY_URL;
172}
173
174// TODO: Drop the undocumented env variables:
175// REACT_NATIVE_PACKAGER_HOSTNAME
176// EXPO_PACKAGER_PROXY_URL
177