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