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