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