1import assert from 'assert'; 2import { URL } from 'url'; 3 4import * as Log from '../../log'; 5import { env } from '../../utils/env'; 6import { getIpAddress } from '../../utils/ip'; 7 8const debug = require('debug')('expo:start:server:urlCreator') as typeof console.log; 9 10export interface CreateURLOptions { 11 /** URL scheme to use when opening apps in custom runtimes. */ 12 scheme?: string | null; 13 /** Type of dev server host to use. */ 14 hostType?: 'localhost' | 'lan' | 'tunnel'; 15 /** Requested hostname. */ 16 hostname?: string | null; 17} 18 19interface UrlComponents { 20 port: string; 21 hostname: string; 22 protocol: string; 23} 24export class UrlCreator { 25 constructor( 26 private defaults: CreateURLOptions | undefined, 27 private bundlerInfo: { port: number; getTunnelUrl?: () => string | null } 28 ) {} 29 30 /** 31 * Return a URL for the "loading" interstitial page that is used to disambiguate which 32 * native runtime to open the dev server with. 33 * 34 * @param options options for creating the URL 35 * @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. 36 * 37 * @returns URL like `http://localhost:19000/_expo/loading?platform=ios` 38 * @returns URL like `http://localhost:19000/_expo/loading` when no platform is provided. 39 */ 40 public constructLoadingUrl(options: CreateURLOptions, platform: string | null): string { 41 const url = new URL('_expo/loading', this.constructUrl({ scheme: 'http', ...options })); 42 if (platform) { 43 url.search = new URLSearchParams({ platform }).toString(); 44 } 45 const loadingUrl = url.toString(); 46 debug(`Loading URL: ${loadingUrl}`); 47 return loadingUrl; 48 } 49 50 /** Create a URI for launching in a native dev client. Returns `null` when no `scheme` can be resolved. */ 51 public constructDevClientUrl(options?: CreateURLOptions): null | string { 52 const protocol = options?.scheme || this.defaults?.scheme; 53 54 if ( 55 !protocol || 56 // Prohibit the use of http(s) in dev client URIs since they'll never be valid. 57 ['http', 'https'].includes(protocol.toLowerCase()) 58 ) { 59 return null; 60 } 61 62 const manifestUrl = this.constructUrl({ ...options, scheme: 'http' }); 63 const devClientUrl = `${protocol}://expo-development-client/?url=${encodeURIComponent( 64 manifestUrl 65 )}`; 66 debug(`Dev client URL: ${devClientUrl} -- manifestUrl: ${manifestUrl} -- %O`, options); 67 return devClientUrl; 68 } 69 70 /** Create a generic URL. */ 71 public constructUrl(options?: Partial<CreateURLOptions> | null): string { 72 const urlComponents = this.getUrlComponents({ 73 ...this.defaults, 74 ...options, 75 }); 76 const url = joinUrlComponents(urlComponents); 77 debug(`URL: ${url}`); 78 return url; 79 } 80 81 /** Get the URL components from the Ngrok server URL. */ 82 private getTunnelUrlComponents(options: Pick<CreateURLOptions, 'scheme'>): UrlComponents | null { 83 const tunnelUrl = this.bundlerInfo.getTunnelUrl?.(); 84 if (!tunnelUrl) { 85 return null; 86 } 87 const parsed = new URL(tunnelUrl); 88 return { 89 port: parsed.port, 90 hostname: parsed.hostname, 91 protocol: options.scheme ?? 'http', 92 }; 93 } 94 95 private getUrlComponents(options: CreateURLOptions): UrlComponents { 96 // Proxy comes first. 97 const proxyURL = getProxyUrl(); 98 if (proxyURL) { 99 return getUrlComponentsFromProxyUrl(options, proxyURL); 100 } 101 102 // Ngrok. 103 if (options.hostType === 'tunnel') { 104 const components = this.getTunnelUrlComponents(options); 105 if (components) { 106 return components; 107 } 108 Log.warn('Tunnel URL not found (it might not be ready yet), falling back to LAN URL.'); 109 } else if (options.hostType === 'localhost' && !options.hostname) { 110 options.hostname = 'localhost'; 111 } 112 113 return { 114 hostname: getDefaultHostname(options), 115 port: this.bundlerInfo.port.toString(), 116 protocol: options.scheme ?? 'http', 117 }; 118 } 119} 120 121function getUrlComponentsFromProxyUrl( 122 options: Pick<CreateURLOptions, 'scheme'>, 123 url: string 124): UrlComponents { 125 const parsedProxyUrl = new URL(url); 126 let protocol = options.scheme ?? 'http'; 127 if (parsedProxyUrl.protocol === 'https:') { 128 if (protocol === 'http') { 129 protocol = 'https'; 130 } 131 if (!parsedProxyUrl.port) { 132 parsedProxyUrl.port = '443'; 133 } 134 } 135 return { 136 port: parsedProxyUrl.port, 137 hostname: parsedProxyUrl.hostname, 138 protocol, 139 }; 140} 141 142function getDefaultHostname(options: Pick<CreateURLOptions, 'hostname'>) { 143 // TODO: Drop REACT_NATIVE_PACKAGER_HOSTNAME 144 if (process.env.REACT_NATIVE_PACKAGER_HOSTNAME) { 145 return process.env.REACT_NATIVE_PACKAGER_HOSTNAME.trim(); 146 } else if (options.hostname === 'localhost') { 147 // Restrict the use of `localhost` 148 // TODO: Note why we do this. 149 return '127.0.0.1'; 150 } 151 152 return options.hostname || getIpAddress(); 153} 154 155function joinUrlComponents({ protocol, hostname, port }: Partial<UrlComponents>): string { 156 assert(hostname, 'hostname cannot be inferred.'); 157 // Android HMR breaks without this port 80. 158 // This is because Android React Native WebSocket implementation is not spec compliant and fails without a port: 159 // `E unknown:ReactNative: java.lang.IllegalArgumentException: Invalid URL port: "-1"` 160 // Invoked first in `metro-runtime/src/modules/HMRClient.js` 161 const validPort = env.EXPO_NO_DEFAULT_PORT ? port : port || '80'; 162 const validProtocol = protocol ? `${protocol}://` : ''; 163 164 let url = `${validProtocol}${hostname}`; 165 166 if (validPort) { 167 url += `:${validPort}`; 168 } 169 170 return url; 171} 172 173/** @deprecated */ 174function getProxyUrl(): string | undefined { 175 return process.env.EXPO_PACKAGER_PROXY_URL; 176} 177 178// TODO: Drop the undocumented env variables: 179// REACT_NATIVE_PACKAGER_HOSTNAME 180// EXPO_PACKAGER_PROXY_URL 181