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 { ExpoMiddleware } from './ExpoMiddleware'; 8import { resolveGoogleServicesFile, resolveManifestAssets } from './resolveAssets'; 9import { resolveAbsoluteEntryPoint } from './resolveEntryPoint'; 10import { parsePlatformHeader, RuntimePlatform } from './resolvePlatform'; 11import { ServerHeaders, ServerNext, ServerRequest, ServerResponse } from './server.types'; 12import * as Log from '../../../log'; 13import { env } from '../../../utils/env'; 14import { stripExtension } from '../../../utils/url'; 15import * as ProjectDevices from '../../project/devices'; 16import { UrlCreator } from '../UrlCreator'; 17import { getPlatformBundlers } from '../platformBundlers'; 18import { createTemplateHtmlFromExpoConfigAsync } from '../webTemplate'; 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( 165 protected projectRoot: string, 166 protected options: ManifestMiddlewareOptions 167 ) { 168 super( 169 projectRoot, 170 /** 171 * Only support `/`, `/manifest`, `/index.exp` for the manifest middleware. 172 */ 173 ['/', '/manifest', '/index.exp'] 174 ); 175 this.initialProjectConfig = getConfig(projectRoot); 176 } 177 178 /** Exposed for testing. */ 179 public async _resolveProjectSettingsAsync({ 180 platform, 181 hostname, 182 }: Pick<TManifestRequestInfo, 'hostname' | 'platform'>): Promise<ResponseProjectSettings> { 183 // Read the config 184 const projectConfig = getConfig(this.projectRoot); 185 186 // Read from headers 187 const mainModuleName = this.resolveMainModuleName(projectConfig, platform); 188 189 // Create the manifest and set fields within it 190 const expoGoConfig = this.getExpoGoConfig({ 191 mainModuleName, 192 hostname, 193 }); 194 195 const hostUri = this.options.constructUrl({ scheme: '', hostname }); 196 197 const bundleUrl = this._getBundleUrl({ 198 platform, 199 mainModuleName, 200 hostname, 201 }); 202 203 // Resolve all assets and set them on the manifest as URLs 204 await this.mutateManifestWithAssetsAsync(projectConfig.exp, bundleUrl); 205 206 return { 207 expoGoConfig, 208 hostUri, 209 bundleUrl, 210 exp: projectConfig.exp, 211 }; 212 } 213 214 /** Get the main entry module ID (file) relative to the project root. */ 215 private resolveMainModuleName(projectConfig: ProjectConfig, platform: string): string { 216 let entryPoint = getEntryWithServerRoot(this.projectRoot, projectConfig, platform); 217 218 debug(`Resolved entry point: ${entryPoint} (project root: ${this.projectRoot})`); 219 220 // NOTE(Bacon): Webpack is currently hardcoded to index.bundle on native 221 // in the future (TODO) we should move this logic into a Webpack plugin and use 222 // a generated file name like we do on web. 223 // const server = getDefaultDevServer(); 224 // // TODO: Move this into BundlerDevServer and read this info from self. 225 // const isNativeWebpack = server instanceof WebpackBundlerDevServer && server.isTargetingNative(); 226 if (this.options.isNativeWebpack) { 227 entryPoint = 'index.js'; 228 } 229 230 return stripExtension(entryPoint, 'js'); 231 } 232 233 /** Parse request headers into options. */ 234 public abstract getParsedHeaders(req: ServerRequest): TManifestRequestInfo; 235 236 /** Store device IDs that were sent in the request headers. */ 237 private async saveDevicesAsync(req: ServerRequest) { 238 const deviceIds = req.headers?.['expo-dev-client-id']; 239 if (deviceIds) { 240 await ProjectDevices.saveDevicesAsync(this.projectRoot, deviceIds).catch((e) => 241 Log.exception(e) 242 ); 243 } 244 } 245 246 /** Create the bundle URL (points to the single JS entry file). Exposed for testing. */ 247 public _getBundleUrl({ 248 platform, 249 mainModuleName, 250 hostname, 251 }: { 252 platform: string; 253 hostname?: string | null; 254 mainModuleName: string; 255 }): string { 256 const path = createBundleUrlPath({ 257 mode: this.options.mode ?? 'development', 258 minify: this.options.minify, 259 platform, 260 mainModuleName, 261 lazy: shouldEnableAsyncImports(this.projectRoot), 262 }); 263 264 return ( 265 this.options.constructUrl({ 266 scheme: 'http', 267 // hostType: this.options.location.hostType, 268 hostname, 269 }) + path 270 ); 271 } 272 273 public _getBundleUrlPath({ 274 platform, 275 mainModuleName, 276 }: { 277 platform: string; 278 mainModuleName: string; 279 }): string { 280 const queryParams = new URLSearchParams({ 281 platform: encodeURIComponent(platform), 282 dev: String(this.options.mode !== 'production'), 283 // TODO: Is this still needed? 284 hot: String(false), 285 }); 286 if (shouldEnableAsyncImports(this.projectRoot)) { 287 queryParams.append('lazy', String(true)); 288 } 289 290 if (this.options.minify) { 291 queryParams.append('minify', String(this.options.minify)); 292 } 293 294 return `/${encodeURI(mainModuleName)}.bundle?${queryParams.toString()}`; 295 } 296 297 /** Log telemetry. */ 298 protected abstract trackManifest(version?: string): void; 299 300 /** Get the manifest response to return to the runtime. This file contains info regarding where the assets can be loaded from. Exposed for testing. */ 301 public abstract _getManifestResponseAsync(options: TManifestRequestInfo): Promise<{ 302 body: string; 303 version: string; 304 headers: ServerHeaders; 305 }>; 306 307 private getExpoGoConfig({ 308 mainModuleName, 309 hostname, 310 }: { 311 mainModuleName: string; 312 hostname?: string | null; 313 }): ExpoGoConfig { 314 return { 315 // localhost:8081 316 debuggerHost: this.options.constructUrl({ scheme: '', hostname }), 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