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