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