1import { ExpoConfig, ExpoGoConfig, getConfig, ProjectConfig } from '@expo/config'; 2import { resolve } from 'url'; 3 4import * as Log from '../../../log'; 5import { stripExtension } from '../../../utils/url'; 6import * as ProjectDevices from '../../project/devices'; 7import { UrlCreator } from '../UrlCreator'; 8import { ExpoMiddleware } from './ExpoMiddleware'; 9import { resolveGoogleServicesFile, resolveManifestAssets } from './resolveAssets'; 10import { resolveEntryPoint } from './resolveEntryPoint'; 11import { RuntimePlatform } from './resolvePlatform'; 12import { ServerHeaders, ServerNext, ServerRequest, ServerResponse } from './server.types'; 13 14/** Info about the computer hosting the dev server. */ 15export interface HostInfo { 16 host: string; 17 server: 'expo'; 18 serverVersion: string; 19 serverDriver: string | null; 20 serverOS: NodeJS.Platform; 21 serverOSVersion: string; 22} 23 24/** Parsed values from the supported request headers. */ 25export interface ParsedHeaders { 26 /** Should return the signed manifest. */ 27 acceptSignature: boolean; 28 /** Platform to serve. */ 29 platform: RuntimePlatform; 30 /** Requested host name. */ 31 hostname?: string | null; 32} 33 34/** Project related info. */ 35export type ResponseProjectSettings = { 36 expoGoConfig: ExpoGoConfig; 37 hostUri: string; 38 bundleUrl: string; 39 exp: ExpoConfig; 40}; 41 42export const DEVELOPER_TOOL = 'expo-cli'; 43 44/** Base middleware creator for serving the Expo manifest (like the index.html but for native runtimes). */ 45export abstract class ManifestMiddleware extends ExpoMiddleware { 46 constructor( 47 protected projectRoot: string, 48 protected options: { 49 /** Should start the dev servers in development mode (minify). */ 50 mode?: 'development' | 'production'; 51 /** Should instruct the bundler to create minified bundles. */ 52 minify?: boolean; 53 constructUrl: UrlCreator['constructUrl']; 54 isNativeWebpack?: boolean; 55 } 56 ) { 57 super( 58 projectRoot, 59 /** 60 * Only support `/`, `/manifest`, `/index.exp` for the manifest middleware. 61 */ 62 ['/', '/manifest', '/index.exp'] 63 ); 64 } 65 66 protected getDefaultResponseHeaders(): Map<string, any> { 67 return new Map<string, any>(); 68 } 69 70 /** Exposed for testing. */ 71 public async _resolveProjectSettingsAsync({ 72 platform, 73 hostname, 74 }: Pick<ParsedHeaders, 'hostname' | 'platform'>): Promise<ResponseProjectSettings> { 75 // Read the config 76 const projectConfig = getConfig(this.projectRoot); 77 78 // Read from headers 79 const mainModuleName = this.resolveMainModuleName(projectConfig, platform); 80 81 // Create the manifest and set fields within it 82 const expoGoConfig = this.getExpoGoConfig({ 83 mainModuleName, 84 hostname, 85 }); 86 87 const hostUri = this.options.constructUrl({ scheme: '', hostname }); 88 89 const bundleUrl = this._getBundleUrl({ 90 platform, 91 mainModuleName, 92 hostname, 93 }); 94 95 // Resolve all assets and set them on the manifest as URLs 96 await this.mutateManifestWithAssetsAsync(projectConfig.exp, bundleUrl); 97 98 return { 99 expoGoConfig, 100 hostUri, 101 bundleUrl, 102 exp: projectConfig.exp, 103 }; 104 } 105 106 /** Get the main entry module ID (file) relative to the project root. */ 107 private resolveMainModuleName(projectConfig: ProjectConfig, platform: string): string { 108 let entryPoint = resolveEntryPoint(this.projectRoot, platform, projectConfig); 109 // NOTE(Bacon): Webpack is currently hardcoded to index.bundle on native 110 // in the future (TODO) we should move this logic into a Webpack plugin and use 111 // a generated file name like we do on web. 112 // const server = getDefaultDevServer(); 113 // // TODO: Move this into BundlerDevServer and read this info from self. 114 // const isNativeWebpack = server instanceof WebpackBundlerDevServer && server.isTargetingNative(); 115 if (this.options.isNativeWebpack) { 116 entryPoint = 'index.js'; 117 } 118 119 return stripExtension(entryPoint, 'js'); 120 } 121 122 /** Parse request headers into options. */ 123 public abstract getParsedHeaders(req: ServerRequest): ParsedHeaders; 124 125 /** Store device IDs that were sent in the request headers. */ 126 private async saveDevicesAsync(req: ServerRequest) { 127 const deviceIds = req.headers?.['expo-dev-client-id']; 128 if (deviceIds) { 129 await ProjectDevices.saveDevicesAsync(this.projectRoot, deviceIds).catch((e) => 130 Log.exception(e) 131 ); 132 } 133 } 134 135 /** Create the bundle URL (points to the single JS entry file). Exposed for testing. */ 136 public _getBundleUrl({ 137 platform, 138 mainModuleName, 139 hostname, 140 }: { 141 platform: string; 142 hostname?: string | null; 143 mainModuleName: string; 144 }): string { 145 const queryParams = new URLSearchParams({ 146 platform: encodeURIComponent(platform), 147 dev: String(this.options.mode !== 'production'), 148 // TODO: Is this still needed? 149 hot: String(false), 150 }); 151 152 if (this.options.minify) { 153 queryParams.append('minify', String(this.options.minify)); 154 } 155 156 const path = `/${encodeURI(mainModuleName)}.bundle?${queryParams.toString()}`; 157 158 return ( 159 this.options.constructUrl({ 160 scheme: 'http', 161 // hostType: this.options.location.hostType, 162 hostname, 163 }) + path 164 ); 165 } 166 167 /** Log telemetry. */ 168 protected abstract trackManifest(version?: string): void; 169 170 /** Get the manifest response to return to the runtime. This file contains info regarding where the assets can be loaded from. Exposed for testing. */ 171 public abstract _getManifestResponseAsync(options: ParsedHeaders): Promise<{ 172 body: string; 173 version: string; 174 headers: ServerHeaders; 175 }>; 176 177 private getExpoGoConfig({ 178 mainModuleName, 179 hostname, 180 }: { 181 mainModuleName: string; 182 hostname?: string | null; 183 }): ExpoGoConfig { 184 return { 185 // localhost:19000 186 debuggerHost: this.options.constructUrl({ scheme: '', hostname }), 187 // http://localhost:19000/logs -- used to send logs to the CLI for displaying in the terminal. 188 // This is deprecated in favor of the WebSocket connection setup in Metro. 189 logUrl: this.options.constructUrl({ scheme: 'http', hostname }) + '/logs', 190 // Required for Expo Go to function. 191 developer: { 192 tool: DEVELOPER_TOOL, 193 projectRoot: this.projectRoot, 194 }, 195 packagerOpts: { 196 // Required for dev client. 197 dev: this.options.mode !== 'production', 198 }, 199 // Indicates the name of the main bundle. 200 mainModuleName, 201 // Add this string to make Flipper register React Native / Metro as "running". 202 // Can be tested by running: 203 // `METRO_SERVER_PORT=19000 open -a flipper.app` 204 // Where 19000 is the port where the Expo project is being hosted. 205 __flipperHack: 'React Native packager is running', 206 }; 207 } 208 209 /** Resolve all assets and set them on the manifest as URLs */ 210 private async mutateManifestWithAssetsAsync(manifest: ExpoConfig, bundleUrl: string) { 211 await resolveManifestAssets(this.projectRoot, { 212 manifest, 213 resolver: async (path) => { 214 if (this.options.isNativeWebpack) { 215 // When using our custom dev server, just do assets normally 216 // without the `assets/` subpath redirect. 217 return resolve(bundleUrl!.match(/^https?:\/\/.*?\//)![0], path); 218 } 219 return bundleUrl!.match(/^https?:\/\/.*?\//)![0] + 'assets/' + path; 220 }, 221 }); 222 // The server normally inserts this but if we're offline we'll do it here 223 await resolveGoogleServicesFile(this.projectRoot, manifest); 224 } 225 226 async handleRequestAsync( 227 req: ServerRequest, 228 res: ServerResponse, 229 next: ServerNext 230 ): Promise<void> { 231 // Save device IDs for dev client. 232 await this.saveDevicesAsync(req); 233 234 // Read from headers 235 const options = this.getParsedHeaders(req); 236 const { body, version, headers } = await this._getManifestResponseAsync(options); 237 for (const [headerName, headerValue] of headers) { 238 res.setHeader(headerName, headerValue); 239 } 240 res.end(body); 241 242 // Log analytics 243 this.trackManifest(version ?? null); 244 } 245} 246