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