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