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