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