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