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