1import { ExpoConfig, ExpoGoConfig, getConfig, ProjectConfig } from '@expo/config'; 2import findWorkspaceRoot from 'find-yarn-workspace-root'; 3import path from 'path'; 4import resolveFrom from 'resolve-from'; 5import { resolve } from 'url'; 6 7import * as Log from '../../../log'; 8import { env } from '../../../utils/env'; 9import { stripExtension } from '../../../utils/url'; 10import * as ProjectDevices from '../../project/devices'; 11import { UrlCreator } from '../UrlCreator'; 12import { getPlatformBundlers } from '../platformBundlers'; 13import { createTemplateHtmlFromExpoConfigAsync } from '../webTemplate'; 14import { ExpoMiddleware } from './ExpoMiddleware'; 15import { resolveGoogleServicesFile, resolveManifestAssets } from './resolveAssets'; 16import { resolveAbsoluteEntryPoint } from './resolveEntryPoint'; 17import { parsePlatformHeader, RuntimePlatform } from './resolvePlatform'; 18import { ServerHeaders, ServerNext, ServerRequest, ServerResponse } from './server.types'; 19 20const debug = require('debug')('expo:start:server:middleware:manifest') as typeof console.log; 21 22/** Wraps `findWorkspaceRoot` and guards against having an empty `package.json` file in an upper directory. */ 23export function getWorkspaceRoot(projectRoot: string): string | null { 24 try { 25 return findWorkspaceRoot(projectRoot); 26 } catch (error: any) { 27 if (error.message.includes('Unexpected end of JSON input')) { 28 return null; 29 } 30 throw error; 31 } 32} 33 34export function getEntryWithServerRoot( 35 projectRoot: string, 36 projectConfig: ProjectConfig, 37 platform: string 38) { 39 return path.relative( 40 getMetroServerRoot(projectRoot), 41 resolveAbsoluteEntryPoint(projectRoot, platform, projectConfig) 42 ); 43} 44 45export function getMetroServerRoot(projectRoot: string) { 46 if (env.EXPO_USE_METRO_WORKSPACE_ROOT) { 47 return getWorkspaceRoot(projectRoot) ?? projectRoot; 48 } 49 50 return projectRoot; 51} 52 53/** Get the main entry module ID (file) relative to the project root. */ 54export function resolveMainModuleName( 55 projectRoot: string, 56 projectConfig: ProjectConfig, 57 platform: string 58): string { 59 const entryPoint = getEntryWithServerRoot(projectRoot, projectConfig, platform); 60 61 debug(`Resolved entry point: ${entryPoint} (project root: ${projectRoot})`); 62 63 return stripExtension(entryPoint, 'js'); 64} 65 66export function shouldEnableAsyncImports(projectRoot: string): boolean { 67 if (env.EXPO_NO_METRO_LAZY) { 68 return false; 69 } 70 71 // `@expo/metro-runtime` includes support for the fetch + eval runtime code required 72 // to support async imports. If it's not installed, we can't support async imports. 73 // If it is installed, the user MUST import it somewhere in their project. 74 // Expo Router automatically pulls this in, so we can check for it. 75 return resolveFrom.silent(projectRoot, '@expo/metro-runtime') != null; 76} 77 78export function createBundleUrlPath({ 79 platform, 80 mainModuleName, 81 mode, 82 minify = mode === 'production', 83 environment, 84 serializerOutput, 85 lazy, 86}: { 87 platform: string; 88 mainModuleName: string; 89 mode: string; 90 minify?: boolean; 91 environment?: string; 92 serializerOutput?: 'static'; 93 lazy?: boolean; 94}): string { 95 const queryParams = new URLSearchParams({ 96 platform: encodeURIComponent(platform), 97 dev: String(mode !== 'production'), 98 // TODO: Is this still needed? 99 hot: String(false), 100 }); 101 102 if (lazy) { 103 queryParams.append('lazy', String(lazy)); 104 } 105 106 if (minify) { 107 queryParams.append('minify', String(minify)); 108 } 109 if (environment) { 110 queryParams.append('resolver.environment', environment); 111 queryParams.append('transform.environment', environment); 112 } 113 if (serializerOutput) { 114 queryParams.append('serializer.output', serializerOutput); 115 } 116 117 return `/${encodeURI(mainModuleName)}.bundle?${queryParams.toString()}`; 118} 119 120/** Info about the computer hosting the dev server. */ 121export interface HostInfo { 122 host: string; 123 server: 'expo'; 124 serverVersion: string; 125 serverDriver: string | null; 126 serverOS: NodeJS.Platform; 127 serverOSVersion: string; 128} 129 130/** Parsed values from the supported request headers. */ 131export interface ManifestRequestInfo { 132 /** Platform to serve. */ 133 platform: RuntimePlatform; 134 /** Requested host name. */ 135 hostname?: string | null; 136} 137 138/** Project related info. */ 139export type ResponseProjectSettings = { 140 expoGoConfig: ExpoGoConfig; 141 hostUri: string; 142 bundleUrl: string; 143 exp: ExpoConfig; 144}; 145 146export const DEVELOPER_TOOL = 'expo-cli'; 147 148export type ManifestMiddlewareOptions = { 149 /** Should start the dev servers in development mode (minify). */ 150 mode?: 'development' | 'production'; 151 /** Should instruct the bundler to create minified bundles. */ 152 minify?: boolean; 153 constructUrl: UrlCreator['constructUrl']; 154 isNativeWebpack?: boolean; 155 privateKeyPath?: string; 156}; 157 158/** Base middleware creator for serving the Expo manifest (like the index.html but for native runtimes). */ 159export abstract class ManifestMiddleware< 160 TManifestRequestInfo extends ManifestRequestInfo 161> extends ExpoMiddleware { 162 private initialProjectConfig: ProjectConfig; 163 164 constructor(protected projectRoot: string, protected options: ManifestMiddlewareOptions) { 165 super( 166 projectRoot, 167 /** 168 * Only support `/`, `/manifest`, `/index.exp` for the manifest middleware. 169 */ 170 ['/', '/manifest', '/index.exp'] 171 ); 172 this.initialProjectConfig = getConfig(projectRoot); 173 } 174 175 /** Exposed for testing. */ 176 public async _resolveProjectSettingsAsync({ 177 platform, 178 hostname, 179 }: Pick<TManifestRequestInfo, 'hostname' | 'platform'>): Promise<ResponseProjectSettings> { 180 // Read the config 181 const projectConfig = getConfig(this.projectRoot); 182 183 // Read from headers 184 const mainModuleName = this.resolveMainModuleName(projectConfig, platform); 185 186 // Create the manifest and set fields within it 187 const expoGoConfig = this.getExpoGoConfig({ 188 mainModuleName, 189 hostname, 190 }); 191 192 const hostUri = this.options.constructUrl({ scheme: '', hostname }); 193 194 const bundleUrl = this._getBundleUrl({ 195 platform, 196 mainModuleName, 197 hostname, 198 }); 199 200 // Resolve all assets and set them on the manifest as URLs 201 await this.mutateManifestWithAssetsAsync(projectConfig.exp, bundleUrl); 202 203 return { 204 expoGoConfig, 205 hostUri, 206 bundleUrl, 207 exp: projectConfig.exp, 208 }; 209 } 210 211 /** Get the main entry module ID (file) relative to the project root. */ 212 private resolveMainModuleName(projectConfig: ProjectConfig, platform: string): string { 213 let entryPoint = getEntryWithServerRoot(this.projectRoot, projectConfig, platform); 214 215 debug(`Resolved entry point: ${entryPoint} (project root: ${this.projectRoot})`); 216 217 // NOTE(Bacon): Webpack is currently hardcoded to index.bundle on native 218 // in the future (TODO) we should move this logic into a Webpack plugin and use 219 // a generated file name like we do on web. 220 // const server = getDefaultDevServer(); 221 // // TODO: Move this into BundlerDevServer and read this info from self. 222 // const isNativeWebpack = server instanceof WebpackBundlerDevServer && server.isTargetingNative(); 223 if (this.options.isNativeWebpack) { 224 entryPoint = 'index.js'; 225 } 226 227 return stripExtension(entryPoint, 'js'); 228 } 229 230 /** Parse request headers into options. */ 231 public abstract getParsedHeaders(req: ServerRequest): TManifestRequestInfo; 232 233 /** Store device IDs that were sent in the request headers. */ 234 private async saveDevicesAsync(req: ServerRequest) { 235 const deviceIds = req.headers?.['expo-dev-client-id']; 236 if (deviceIds) { 237 await ProjectDevices.saveDevicesAsync(this.projectRoot, deviceIds).catch((e) => 238 Log.exception(e) 239 ); 240 } 241 } 242 243 /** Create the bundle URL (points to the single JS entry file). Exposed for testing. */ 244 public _getBundleUrl({ 245 platform, 246 mainModuleName, 247 hostname, 248 }: { 249 platform: string; 250 hostname?: string | null; 251 mainModuleName: string; 252 }): string { 253 const path = createBundleUrlPath({ 254 mode: this.options.mode ?? 'development', 255 minify: this.options.minify, 256 platform, 257 mainModuleName, 258 lazy: shouldEnableAsyncImports(this.projectRoot), 259 }); 260 261 return ( 262 this.options.constructUrl({ 263 scheme: 'http', 264 // hostType: this.options.location.hostType, 265 hostname, 266 }) + path 267 ); 268 } 269 270 public _getBundleUrlPath({ 271 platform, 272 mainModuleName, 273 }: { 274 platform: string; 275 mainModuleName: string; 276 }): string { 277 const queryParams = new URLSearchParams({ 278 platform: encodeURIComponent(platform), 279 dev: String(this.options.mode !== 'production'), 280 // TODO: Is this still needed? 281 hot: String(false), 282 }); 283 if (shouldEnableAsyncImports(this.projectRoot)) { 284 queryParams.append('lazy', String(true)); 285 } 286 287 if (this.options.minify) { 288 queryParams.append('minify', String(this.options.minify)); 289 } 290 291 return `/${encodeURI(mainModuleName)}.bundle?${queryParams.toString()}`; 292 } 293 294 /** Log telemetry. */ 295 protected abstract trackManifest(version?: string): void; 296 297 /** Get the manifest response to return to the runtime. This file contains info regarding where the assets can be loaded from. Exposed for testing. */ 298 public abstract _getManifestResponseAsync(options: TManifestRequestInfo): Promise<{ 299 body: string; 300 version: string; 301 headers: ServerHeaders; 302 }>; 303 304 private getExpoGoConfig({ 305 mainModuleName, 306 hostname, 307 }: { 308 mainModuleName: string; 309 hostname?: string | null; 310 }): ExpoGoConfig { 311 return { 312 // localhost:8081 313 debuggerHost: this.options.constructUrl({ scheme: '', hostname }), 314 // http://localhost:8081/logs -- used to send logs to the CLI for displaying in the terminal. 315 // This is deprecated in favor of the WebSocket connection setup in Metro. 316 logUrl: this.options.constructUrl({ scheme: 'http', hostname }) + '/logs', 317 // Required for Expo Go to function. 318 developer: { 319 tool: DEVELOPER_TOOL, 320 projectRoot: this.projectRoot, 321 }, 322 packagerOpts: { 323 // Required for dev client. 324 dev: this.options.mode !== 'production', 325 }, 326 // Indicates the name of the main bundle. 327 mainModuleName, 328 // Add this string to make Flipper register React Native / Metro as "running". 329 // Can be tested by running: 330 // `METRO_SERVER_PORT=8081 open -a flipper.app` 331 // Where 8081 is the port where the Expo project is being hosted. 332 __flipperHack: 'React Native packager is running', 333 }; 334 } 335 336 /** Resolve all assets and set them on the manifest as URLs */ 337 private async mutateManifestWithAssetsAsync(manifest: ExpoConfig, bundleUrl: string) { 338 await resolveManifestAssets(this.projectRoot, { 339 manifest, 340 resolver: async (path) => { 341 if (this.options.isNativeWebpack) { 342 // When using our custom dev server, just do assets normally 343 // without the `assets/` subpath redirect. 344 return resolve(bundleUrl!.match(/^https?:\/\/.*?\//)![0], path); 345 } 346 return bundleUrl!.match(/^https?:\/\/.*?\//)![0] + 'assets/' + path; 347 }, 348 }); 349 // The server normally inserts this but if we're offline we'll do it here 350 await resolveGoogleServicesFile(this.projectRoot, manifest); 351 } 352 353 public getWebBundleUrl() { 354 const platform = 'web'; 355 // Read from headers 356 const mainModuleName = this.resolveMainModuleName(this.initialProjectConfig, platform); 357 return this._getBundleUrlPath({ 358 platform, 359 mainModuleName, 360 }); 361 } 362 363 /** 364 * Web platforms should create an index.html response using the same script resolution as native. 365 * 366 * Instead of adding a `bundleUrl` to a `manifest.json` (native) we'll add a `<script src="">` 367 * to an `index.html`, this enables the web platform to load JavaScript from the server. 368 */ 369 private async handleWebRequestAsync(req: ServerRequest, res: ServerResponse) { 370 // Read from headers 371 const bundleUrl = this.getWebBundleUrl(); 372 373 res.setHeader('Content-Type', 'text/html'); 374 375 res.end( 376 await createTemplateHtmlFromExpoConfigAsync(this.projectRoot, { 377 exp: this.initialProjectConfig.exp, 378 scripts: [bundleUrl], 379 }) 380 ); 381 } 382 383 /** Exposed for testing. */ 384 async checkBrowserRequestAsync(req: ServerRequest, res: ServerResponse, next: ServerNext) { 385 // Read the config 386 const bundlers = getPlatformBundlers(this.initialProjectConfig.exp); 387 if (bundlers.web === 'metro') { 388 // NOTE(EvanBacon): This effectively disables the safety check we do on custom runtimes to ensure 389 // the `expo-platform` header is included. When `web.bundler=web`, if the user has non-standard Expo 390 // code loading then they'll get a web bundle without a clear assertion of platform support. 391 const platform = parsePlatformHeader(req); 392 // On web, serve the public folder 393 if (!platform || platform === 'web') { 394 if (this.initialProjectConfig.exp.web?.output === 'static') { 395 // Skip the spa-styled index.html when static generation is enabled. 396 next(); 397 return true; 398 } else { 399 await this.handleWebRequestAsync(req, res); 400 return true; 401 } 402 } 403 } 404 return false; 405 } 406 407 async handleRequestAsync( 408 req: ServerRequest, 409 res: ServerResponse, 410 next: ServerNext 411 ): Promise<void> { 412 // First check for standard JavaScript runtimes (aka legacy browsers like Chrome). 413 if (await this.checkBrowserRequestAsync(req, res, next)) { 414 return; 415 } 416 417 // Save device IDs for dev client. 418 await this.saveDevicesAsync(req); 419 420 // Read from headers 421 const options = this.getParsedHeaders(req); 422 const { body, version, headers } = await this._getManifestResponseAsync(options); 423 for (const [headerName, headerValue] of headers) { 424 res.setHeader(headerName, headerValue); 425 } 426 res.end(body); 427 428 // Log analytics 429 this.trackManifest(version ?? null); 430 } 431} 432