18d307f52SEvan Baconimport assert from 'assert';
28d307f52SEvan Baconimport { URL } from 'url';
38d307f52SEvan Bacon
48d307f52SEvan Baconimport * as Log from '../../log';
58d307f52SEvan Baconimport { getIpAddress } from '../../utils/ip';
68d307f52SEvan Bacon
7474a7a4bSEvan Baconconst debug = require('debug')('expo:start:server:urlCreator') as typeof console.log;
8474a7a4bSEvan Bacon
98d307f52SEvan Baconexport interface CreateURLOptions {
108d307f52SEvan Bacon  /** URL scheme to use when opening apps in custom runtimes. */
118d307f52SEvan Bacon  scheme?: string | null;
128d307f52SEvan Bacon  /** Type of dev server host to use. */
138d307f52SEvan Bacon  hostType?: 'localhost' | 'lan' | 'tunnel';
148d307f52SEvan Bacon  /** Requested hostname. */
1529975bfdSEvan Bacon  hostname?: string | null;
168d307f52SEvan Bacon}
178d307f52SEvan Bacon
188d307f52SEvan Baconinterface UrlComponents {
198d307f52SEvan Bacon  port: string;
208d307f52SEvan Bacon  hostname: string;
218d307f52SEvan Bacon  protocol: string;
228d307f52SEvan Bacon}
238d307f52SEvan Baconexport class UrlCreator {
248d307f52SEvan Bacon  constructor(
25a7e47f4dSEvan Bacon    public defaults: CreateURLOptions | undefined,
2629975bfdSEvan Bacon    private bundlerInfo: { port: number; getTunnelUrl?: () => string | null }
278d307f52SEvan Bacon  ) {}
288d307f52SEvan Bacon
298d307f52SEvan Bacon  /**
30212e3a1aSEric Samelson   * Return a URL for the "loading" interstitial page that is used to disambiguate which
31212e3a1aSEric Samelson   * native runtime to open the dev server with.
32212e3a1aSEric Samelson   *
33212e3a1aSEric Samelson   * @param options options for creating the URL
34212e3a1aSEric Samelson   * @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.
35212e3a1aSEric Samelson   *
3647d62600SKudo Chien   * @returns URL like `http://localhost:8081/_expo/loading?platform=ios`
3747d62600SKudo Chien   * @returns URL like `http://localhost:8081/_expo/loading` when no platform is provided.
388d307f52SEvan Bacon   */
39212e3a1aSEric Samelson  public constructLoadingUrl(options: CreateURLOptions, platform: string | null): string {
408d307f52SEvan Bacon    const url = new URL('_expo/loading', this.constructUrl({ scheme: 'http', ...options }));
41212e3a1aSEric Samelson    if (platform) {
428d307f52SEvan Bacon      url.search = new URLSearchParams({ platform }).toString();
43212e3a1aSEric Samelson    }
44474a7a4bSEvan Bacon    const loadingUrl = url.toString();
45474a7a4bSEvan Bacon    debug(`Loading URL: ${loadingUrl}`);
46474a7a4bSEvan Bacon    return loadingUrl;
478d307f52SEvan Bacon  }
488d307f52SEvan Bacon
498d307f52SEvan Bacon  /** Create a URI for launching in a native dev client. Returns `null` when no `scheme` can be resolved. */
508d307f52SEvan Bacon  public constructDevClientUrl(options?: CreateURLOptions): null | string {
513d6e487dSEvan Bacon    const protocol = options?.scheme || this.defaults?.scheme;
528d307f52SEvan Bacon
538d307f52SEvan Bacon    if (
548d307f52SEvan Bacon      !protocol ||
558d307f52SEvan Bacon      // Prohibit the use of http(s) in dev client URIs since they'll never be valid.
56*847c099dSCedric van Putten      ['http', 'https'].includes(protocol.toLowerCase()) ||
57*847c099dSCedric van Putten      // Prohibit the use of `_` characters in the protocol, Node will throw an error when parsing these URLs
58*847c099dSCedric van Putten      protocol.includes('_')
598d307f52SEvan Bacon    ) {
608d307f52SEvan Bacon      return null;
618d307f52SEvan Bacon    }
628d307f52SEvan Bacon
638d307f52SEvan Bacon    const manifestUrl = this.constructUrl({ ...options, scheme: 'http' });
64474a7a4bSEvan Bacon    const devClientUrl = `${protocol}://expo-development-client/?url=${encodeURIComponent(
65474a7a4bSEvan Bacon      manifestUrl
66474a7a4bSEvan Bacon    )}`;
67474a7a4bSEvan Bacon    debug(`Dev client URL: ${devClientUrl} -- manifestUrl: ${manifestUrl} -- %O`, options);
68474a7a4bSEvan Bacon    return devClientUrl;
698d307f52SEvan Bacon  }
708d307f52SEvan Bacon
718d307f52SEvan Bacon  /** Create a generic URL. */
728d307f52SEvan Bacon  public constructUrl(options?: Partial<CreateURLOptions> | null): string {
738d307f52SEvan Bacon    const urlComponents = this.getUrlComponents({
748d307f52SEvan Bacon      ...this.defaults,
758d307f52SEvan Bacon      ...options,
768d307f52SEvan Bacon    });
77474a7a4bSEvan Bacon    const url = joinUrlComponents(urlComponents);
78474a7a4bSEvan Bacon    debug(`URL: ${url}`);
79474a7a4bSEvan Bacon    return url;
808d307f52SEvan Bacon  }
818d307f52SEvan Bacon
828d307f52SEvan Bacon  /** Get the URL components from the Ngrok server URL. */
838d307f52SEvan Bacon  private getTunnelUrlComponents(options: Pick<CreateURLOptions, 'scheme'>): UrlComponents | null {
8429975bfdSEvan Bacon    const tunnelUrl = this.bundlerInfo.getTunnelUrl?.();
858d307f52SEvan Bacon    if (!tunnelUrl) {
868d307f52SEvan Bacon      return null;
878d307f52SEvan Bacon    }
888d307f52SEvan Bacon    const parsed = new URL(tunnelUrl);
898d307f52SEvan Bacon    return {
908d307f52SEvan Bacon      port: parsed.port,
918d307f52SEvan Bacon      hostname: parsed.hostname,
928d307f52SEvan Bacon      protocol: options.scheme ?? 'http',
938d307f52SEvan Bacon    };
948d307f52SEvan Bacon  }
958d307f52SEvan Bacon
968d307f52SEvan Bacon  private getUrlComponents(options: CreateURLOptions): UrlComponents {
978d307f52SEvan Bacon    // Proxy comes first.
988d307f52SEvan Bacon    const proxyURL = getProxyUrl();
998d307f52SEvan Bacon    if (proxyURL) {
1008d307f52SEvan Bacon      return getUrlComponentsFromProxyUrl(options, proxyURL);
1018d307f52SEvan Bacon    }
1028d307f52SEvan Bacon
1038d307f52SEvan Bacon    // Ngrok.
1048d307f52SEvan Bacon    if (options.hostType === 'tunnel') {
1058d307f52SEvan Bacon      const components = this.getTunnelUrlComponents(options);
1068d307f52SEvan Bacon      if (components) {
1078d307f52SEvan Bacon        return components;
1088d307f52SEvan Bacon      }
1098d307f52SEvan Bacon      Log.warn('Tunnel URL not found (it might not be ready yet), falling back to LAN URL.');
1108d307f52SEvan Bacon    } else if (options.hostType === 'localhost' && !options.hostname) {
1118d307f52SEvan Bacon      options.hostname = 'localhost';
1128d307f52SEvan Bacon    }
1138d307f52SEvan Bacon
1148d307f52SEvan Bacon    return {
1158d307f52SEvan Bacon      hostname: getDefaultHostname(options),
1168d307f52SEvan Bacon      port: this.bundlerInfo.port.toString(),
1178d307f52SEvan Bacon      protocol: options.scheme ?? 'http',
1188d307f52SEvan Bacon    };
1198d307f52SEvan Bacon  }
1208d307f52SEvan Bacon}
1218d307f52SEvan Bacon
1228d307f52SEvan Baconfunction getUrlComponentsFromProxyUrl(
1238d307f52SEvan Bacon  options: Pick<CreateURLOptions, 'scheme'>,
1248d307f52SEvan Bacon  url: string
1258d307f52SEvan Bacon): UrlComponents {
1268d307f52SEvan Bacon  const parsedProxyUrl = new URL(url);
1278d307f52SEvan Bacon  let protocol = options.scheme ?? 'http';
1288d307f52SEvan Bacon  if (parsedProxyUrl.protocol === 'https:') {
1298d307f52SEvan Bacon    if (protocol === 'http') {
1308d307f52SEvan Bacon      protocol = 'https';
1318d307f52SEvan Bacon    }
1328d307f52SEvan Bacon    if (!parsedProxyUrl.port) {
1338d307f52SEvan Bacon      parsedProxyUrl.port = '443';
1348d307f52SEvan Bacon    }
1358d307f52SEvan Bacon  }
1368d307f52SEvan Bacon  return {
1378d307f52SEvan Bacon    port: parsedProxyUrl.port,
1388d307f52SEvan Bacon    hostname: parsedProxyUrl.hostname,
1398d307f52SEvan Bacon    protocol,
1408d307f52SEvan Bacon  };
1418d307f52SEvan Bacon}
1428d307f52SEvan Bacon
1438d307f52SEvan Baconfunction getDefaultHostname(options: Pick<CreateURLOptions, 'hostname'>) {
1448d307f52SEvan Bacon  // TODO: Drop REACT_NATIVE_PACKAGER_HOSTNAME
1458d307f52SEvan Bacon  if (process.env.REACT_NATIVE_PACKAGER_HOSTNAME) {
1468d307f52SEvan Bacon    return process.env.REACT_NATIVE_PACKAGER_HOSTNAME.trim();
1478d307f52SEvan Bacon  } else if (options.hostname === 'localhost') {
1488d307f52SEvan Bacon    // Restrict the use of `localhost`
1498d307f52SEvan Bacon    // TODO: Note why we do this.
1508d307f52SEvan Bacon    return '127.0.0.1';
1518d307f52SEvan Bacon  }
1528d307f52SEvan Bacon
1538d307f52SEvan Bacon  return options.hostname || getIpAddress();
1548d307f52SEvan Bacon}
1558d307f52SEvan Bacon
1568d307f52SEvan Baconfunction joinUrlComponents({ protocol, hostname, port }: Partial<UrlComponents>): string {
1578d307f52SEvan Bacon  assert(hostname, 'hostname cannot be inferred.');
1588d307f52SEvan Bacon  const validProtocol = protocol ? `${protocol}://` : '';
1598d307f52SEvan Bacon
16048783050SEvan Bacon  const url = `${validProtocol}${hostname}`;
1619afd2165SEvan Bacon
16248783050SEvan Bacon  if (port) {
16348783050SEvan Bacon    return url + `:${port}`;
1649afd2165SEvan Bacon  }
1659afd2165SEvan Bacon
1669afd2165SEvan Bacon  return url;
1678d307f52SEvan Bacon}
1688d307f52SEvan Bacon
1698d307f52SEvan Bacon/** @deprecated */
1708d307f52SEvan Baconfunction getProxyUrl(): string | undefined {
1718d307f52SEvan Bacon  return process.env.EXPO_PACKAGER_PROXY_URL;
1728d307f52SEvan Bacon}
1738d307f52SEvan Bacon
1748d307f52SEvan Bacon// TODO: Drop the undocumented env variables:
1758d307f52SEvan Bacon// REACT_NATIVE_PACKAGER_HOSTNAME
1768d307f52SEvan Bacon// EXPO_PACKAGER_PROXY_URL
177