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 // Required for Expo Go to function. 315 developer: { 316 tool: DEVELOPER_TOOL, 317 projectRoot: this.projectRoot, 318 }, 319 packagerOpts: { 320 // Required for dev client. 321 dev: this.options.mode !== 'production', 322 }, 323 // Indicates the name of the main bundle. 324 mainModuleName, 325 // Add this string to make Flipper register React Native / Metro as "running". 326 // Can be tested by running: 327 // `METRO_SERVER_PORT=8081 open -a flipper.app` 328 // Where 8081 is the port where the Expo project is being hosted. 329 __flipperHack: 'React Native packager is running', 330 }; 331 } 332 333 /** Resolve all assets and set them on the manifest as URLs */ 334 private async mutateManifestWithAssetsAsync(manifest: ExpoConfig, bundleUrl: string) { 335 await resolveManifestAssets(this.projectRoot, { 336 manifest, 337 resolver: async (path) => { 338 if (this.options.isNativeWebpack) { 339 // When using our custom dev server, just do assets normally 340 // without the `assets/` subpath redirect. 341 return resolve(bundleUrl!.match(/^https?:\/\/.*?\//)![0], path); 342 } 343 return bundleUrl!.match(/^https?:\/\/.*?\//)![0] + 'assets/' + path; 344 }, 345 }); 346 // The server normally inserts this but if we're offline we'll do it here 347 await resolveGoogleServicesFile(this.projectRoot, manifest); 348 } 349 350 public getWebBundleUrl() { 351 const platform = 'web'; 352 // Read from headers 353 const mainModuleName = this.resolveMainModuleName(this.initialProjectConfig, platform); 354 return this._getBundleUrlPath({ 355 platform, 356 mainModuleName, 357 }); 358 } 359 360 /** 361 * Web platforms should create an index.html response using the same script resolution as native. 362 * 363 * Instead of adding a `bundleUrl` to a `manifest.json` (native) we'll add a `<script src="">` 364 * to an `index.html`, this enables the web platform to load JavaScript from the server. 365 */ 366 private async handleWebRequestAsync(req: ServerRequest, res: ServerResponse) { 367 // Read from headers 368 const bundleUrl = this.getWebBundleUrl(); 369 370 res.setHeader('Content-Type', 'text/html'); 371 372 res.end( 373 await createTemplateHtmlFromExpoConfigAsync(this.projectRoot, { 374 exp: this.initialProjectConfig.exp, 375 scripts: [bundleUrl], 376 }) 377 ); 378 } 379 380 /** Exposed for testing. */ 381 async checkBrowserRequestAsync(req: ServerRequest, res: ServerResponse, next: ServerNext) { 382 // Read the config 383 const bundlers = getPlatformBundlers(this.initialProjectConfig.exp); 384 if (bundlers.web === 'metro') { 385 // NOTE(EvanBacon): This effectively disables the safety check we do on custom runtimes to ensure 386 // the `expo-platform` header is included. When `web.bundler=web`, if the user has non-standard Expo 387 // code loading then they'll get a web bundle without a clear assertion of platform support. 388 const platform = parsePlatformHeader(req); 389 // On web, serve the public folder 390 if (!platform || platform === 'web') { 391 if (this.initialProjectConfig.exp.web?.output === 'static') { 392 // Skip the spa-styled index.html when static generation is enabled. 393 next(); 394 return true; 395 } else { 396 await this.handleWebRequestAsync(req, res); 397 return true; 398 } 399 } 400 } 401 return false; 402 } 403 404 async handleRequestAsync( 405 req: ServerRequest, 406 res: ServerResponse, 407 next: ServerNext 408 ): Promise<void> { 409 // First check for standard JavaScript runtimes (aka legacy browsers like Chrome). 410 if (await this.checkBrowserRequestAsync(req, res, next)) { 411 return; 412 } 413 414 // Save device IDs for dev client. 415 await this.saveDevicesAsync(req); 416 417 // Read from headers 418 const options = this.getParsedHeaders(req); 419 const { body, version, headers } = await this._getManifestResponseAsync(options); 420 for (const [headerName, headerValue] of headers) { 421 res.setHeader(headerName, headerValue); 422 } 423 res.end(body); 424 425 // Log analytics 426 this.trackManifest(version ?? null); 427 } 428} 429