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