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