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 public 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:8081/_expo/loading?platform=ios` 37 * @returns URL like `http://localhost:8081/_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 // Prohibit the use of `_` characters in the protocol, Node will throw an error when parsing these URLs 58 protocol.includes('_') 59 ) { 60 return null; 61 } 62 63 const manifestUrl = this.constructUrl({ ...options, scheme: 'http' }); 64 const devClientUrl = `${protocol}://expo-development-client/?url=${encodeURIComponent( 65 manifestUrl 66 )}`; 67 debug(`Dev client URL: ${devClientUrl} -- manifestUrl: ${manifestUrl} -- %O`, options); 68 return devClientUrl; 69 } 70 71 /** Create a generic URL. */ 72 public constructUrl(options?: Partial<CreateURLOptions> | null): string { 73 const urlComponents = this.getUrlComponents({ 74 ...this.defaults, 75 ...options, 76 }); 77 const url = joinUrlComponents(urlComponents); 78 debug(`URL: ${url}`); 79 return url; 80 } 81 82 /** Get the URL components from the Ngrok server URL. */ 83 private getTunnelUrlComponents(options: Pick<CreateURLOptions, 'scheme'>): UrlComponents | null { 84 const tunnelUrl = this.bundlerInfo.getTunnelUrl?.(); 85 if (!tunnelUrl) { 86 return null; 87 } 88 const parsed = new URL(tunnelUrl); 89 return { 90 port: parsed.port, 91 hostname: parsed.hostname, 92 protocol: options.scheme ?? 'http', 93 }; 94 } 95 96 private getUrlComponents(options: CreateURLOptions): UrlComponents { 97 // Proxy comes first. 98 const proxyURL = getProxyUrl(); 99 if (proxyURL) { 100 return getUrlComponentsFromProxyUrl(options, proxyURL); 101 } 102 103 // Ngrok. 104 if (options.hostType === 'tunnel') { 105 const components = this.getTunnelUrlComponents(options); 106 if (components) { 107 return components; 108 } 109 Log.warn('Tunnel URL not found (it might not be ready yet), falling back to LAN URL.'); 110 } else if (options.hostType === 'localhost' && !options.hostname) { 111 options.hostname = 'localhost'; 112 } 113 114 return { 115 hostname: getDefaultHostname(options), 116 port: this.bundlerInfo.port.toString(), 117 protocol: options.scheme ?? 'http', 118 }; 119 } 120} 121 122function getUrlComponentsFromProxyUrl( 123 options: Pick<CreateURLOptions, 'scheme'>, 124 url: string 125): UrlComponents { 126 const parsedProxyUrl = new URL(url); 127 let protocol = options.scheme ?? 'http'; 128 if (parsedProxyUrl.protocol === 'https:') { 129 if (protocol === 'http') { 130 protocol = 'https'; 131 } 132 if (!parsedProxyUrl.port) { 133 parsedProxyUrl.port = '443'; 134 } 135 } 136 return { 137 port: parsedProxyUrl.port, 138 hostname: parsedProxyUrl.hostname, 139 protocol, 140 }; 141} 142 143function getDefaultHostname(options: Pick<CreateURLOptions, 'hostname'>) { 144 // TODO: Drop REACT_NATIVE_PACKAGER_HOSTNAME 145 if (process.env.REACT_NATIVE_PACKAGER_HOSTNAME) { 146 return process.env.REACT_NATIVE_PACKAGER_HOSTNAME.trim(); 147 } else if (options.hostname === 'localhost') { 148 // Restrict the use of `localhost` 149 // TODO: Note why we do this. 150 return '127.0.0.1'; 151 } 152 153 return options.hostname || getIpAddress(); 154} 155 156function joinUrlComponents({ protocol, hostname, port }: Partial<UrlComponents>): string { 157 assert(hostname, 'hostname cannot be inferred.'); 158 const validProtocol = protocol ? `${protocol}://` : ''; 159 160 const url = `${validProtocol}${hostname}`; 161 162 if (port) { 163 return url + `:${port}`; 164 } 165 166 return url; 167} 168 169/** @deprecated */ 170function getProxyUrl(): string | undefined { 171 return process.env.EXPO_PACKAGER_PROXY_URL; 172} 173 174// TODO: Drop the undocumented env variables: 175// REACT_NATIVE_PACKAGER_HOSTNAME 176// EXPO_PACKAGER_PROXY_URL 177