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