1import { ExpoConfig, ExpoGoConfig, getConfig, ProjectConfig } from '@expo/config'; 2import { resolve } from 'url'; 3 4import * as Log from '../../../log'; 5import { stripExtension } from '../../../utils/url'; 6import * as ProjectDevices from '../../project/devices'; 7import { UrlCreator } from '../UrlCreator'; 8import { ExpoMiddleware } from './ExpoMiddleware'; 9import { resolveGoogleServicesFile, resolveManifestAssets } from './resolveAssets'; 10import { resolveEntryPoint } from './resolveEntryPoint'; 11import { RuntimePlatform } from './resolvePlatform'; 12import { ServerHeaders, ServerNext, ServerRequest, ServerResponse } from './server.types'; 13 14/** Info about the computer hosting the dev server. */ 15export interface HostInfo { 16 host: string; 17 server: 'expo'; 18 serverVersion: string; 19 serverDriver: string | null; 20 serverOS: NodeJS.Platform; 21 serverOSVersion: string; 22} 23 24/** Parsed values from the supported request headers. */ 25export interface ManifestRequestInfo { 26 /** Should return the signed manifest. */ 27 acceptSignature: boolean; 28 /** Platform to serve. */ 29 platform: RuntimePlatform; 30 /** Requested host name. */ 31 hostname?: string | null; 32} 33 34/** Project related info. */ 35export type ResponseProjectSettings = { 36 expoGoConfig: ExpoGoConfig; 37 hostUri: string; 38 bundleUrl: string; 39 exp: ExpoConfig; 40}; 41 42export const DEVELOPER_TOOL = 'expo-cli'; 43 44export type ManifestMiddlewareOptions = { 45 /** Should start the dev servers in development mode (minify). */ 46 mode?: 'development' | 'production'; 47 /** Should instruct the bundler to create minified bundles. */ 48 minify?: boolean; 49 constructUrl: UrlCreator['constructUrl']; 50 isNativeWebpack?: boolean; 51 privateKeyPath?: string; 52}; 53 54/** Base middleware creator for serving the Expo manifest (like the index.html but for native runtimes). */ 55export abstract class ManifestMiddleware< 56 TManifestRequestInfo extends ManifestRequestInfo 57> extends ExpoMiddleware { 58 constructor(protected projectRoot: string, protected options: ManifestMiddlewareOptions) { 59 super( 60 projectRoot, 61 /** 62 * Only support `/`, `/manifest`, `/index.exp` for the manifest middleware. 63 */ 64 ['/', '/manifest', '/index.exp'] 65 ); 66 } 67 68 /** Exposed for testing. */ 69 public async _resolveProjectSettingsAsync({ 70 platform, 71 hostname, 72 }: Pick<TManifestRequestInfo, 'hostname' | 'platform'>): Promise<ResponseProjectSettings> { 73 // Read the config 74 const projectConfig = getConfig(this.projectRoot); 75 76 // Read from headers 77 const mainModuleName = this.resolveMainModuleName(projectConfig, platform); 78 79 // Create the manifest and set fields within it 80 const expoGoConfig = this.getExpoGoConfig({ 81 mainModuleName, 82 hostname, 83 }); 84 85 const hostUri = this.options.constructUrl({ scheme: '', hostname }); 86 87 const bundleUrl = this._getBundleUrl({ 88 platform, 89 mainModuleName, 90 hostname, 91 }); 92 93 // Resolve all assets and set them on the manifest as URLs 94 await this.mutateManifestWithAssetsAsync(projectConfig.exp, bundleUrl); 95 96 return { 97 expoGoConfig, 98 hostUri, 99 bundleUrl, 100 exp: projectConfig.exp, 101 }; 102 } 103 104 /** Get the main entry module ID (file) relative to the project root. */ 105 private resolveMainModuleName(projectConfig: ProjectConfig, platform: string): string { 106 let entryPoint = resolveEntryPoint(this.projectRoot, platform, projectConfig); 107 // NOTE(Bacon): Webpack is currently hardcoded to index.bundle on native 108 // in the future (TODO) we should move this logic into a Webpack plugin and use 109 // a generated file name like we do on web. 110 // const server = getDefaultDevServer(); 111 // // TODO: Move this into BundlerDevServer and read this info from self. 112 // const isNativeWebpack = server instanceof WebpackBundlerDevServer && server.isTargetingNative(); 113 if (this.options.isNativeWebpack) { 114 entryPoint = 'index.js'; 115 } 116 117 return stripExtension(entryPoint, 'js'); 118 } 119 120 /** Parse request headers into options. */ 121 public abstract getParsedHeaders(req: ServerRequest): TManifestRequestInfo; 122 123 /** Store device IDs that were sent in the request headers. */ 124 private async saveDevicesAsync(req: ServerRequest) { 125 const deviceIds = req.headers?.['expo-dev-client-id']; 126 if (deviceIds) { 127 await ProjectDevices.saveDevicesAsync(this.projectRoot, deviceIds).catch((e) => 128 Log.exception(e) 129 ); 130 } 131 } 132 133 /** Create the bundle URL (points to the single JS entry file). Exposed for testing. */ 134 public _getBundleUrl({ 135 platform, 136 mainModuleName, 137 hostname, 138 }: { 139 platform: string; 140 hostname?: string | null; 141 mainModuleName: string; 142 }): string { 143 const queryParams = new URLSearchParams({ 144 platform: encodeURIComponent(platform), 145 dev: String(this.options.mode !== 'production'), 146 // TODO: Is this still needed? 147 hot: String(false), 148 }); 149 150 if (this.options.minify) { 151 queryParams.append('minify', String(this.options.minify)); 152 } 153 154 const path = `/${encodeURI(mainModuleName)}.bundle?${queryParams.toString()}`; 155 156 return ( 157 this.options.constructUrl({ 158 scheme: 'http', 159 // hostType: this.options.location.hostType, 160 hostname, 161 }) + path 162 ); 163 } 164 165 /** Log telemetry. */ 166 protected abstract trackManifest(version?: string): void; 167 168 /** Get the manifest response to return to the runtime. This file contains info regarding where the assets can be loaded from. Exposed for testing. */ 169 public abstract _getManifestResponseAsync(options: TManifestRequestInfo): Promise<{ 170 body: string; 171 version: string; 172 headers: ServerHeaders; 173 }>; 174 175 private getExpoGoConfig({ 176 mainModuleName, 177 hostname, 178 }: { 179 mainModuleName: string; 180 hostname?: string | null; 181 }): ExpoGoConfig { 182 return { 183 // localhost:19000 184 debuggerHost: this.options.constructUrl({ scheme: '', hostname }), 185 // http://localhost:19000/logs -- used to send logs to the CLI for displaying in the terminal. 186 // This is deprecated in favor of the WebSocket connection setup in Metro. 187 logUrl: this.options.constructUrl({ scheme: 'http', hostname }) + '/logs', 188 // Required for Expo Go to function. 189 developer: { 190 tool: DEVELOPER_TOOL, 191 projectRoot: this.projectRoot, 192 }, 193 packagerOpts: { 194 // Required for dev client. 195 dev: this.options.mode !== 'production', 196 }, 197 // Indicates the name of the main bundle. 198 mainModuleName, 199 // Add this string to make Flipper register React Native / Metro as "running". 200 // Can be tested by running: 201 // `METRO_SERVER_PORT=19000 open -a flipper.app` 202 // Where 19000 is the port where the Expo project is being hosted. 203 __flipperHack: 'React Native packager is running', 204 }; 205 } 206 207 /** Resolve all assets and set them on the manifest as URLs */ 208 private async mutateManifestWithAssetsAsync(manifest: ExpoConfig, bundleUrl: string) { 209 await resolveManifestAssets(this.projectRoot, { 210 manifest, 211 resolver: async (path) => { 212 if (this.options.isNativeWebpack) { 213 // When using our custom dev server, just do assets normally 214 // without the `assets/` subpath redirect. 215 return resolve(bundleUrl!.match(/^https?:\/\/.*?\//)![0], path); 216 } 217 return bundleUrl!.match(/^https?:\/\/.*?\//)![0] + 'assets/' + path; 218 }, 219 }); 220 // The server normally inserts this but if we're offline we'll do it here 221 await resolveGoogleServicesFile(this.projectRoot, manifest); 222 } 223 224 async handleRequestAsync( 225 req: ServerRequest, 226 res: ServerResponse, 227 next: ServerNext 228 ): Promise<void> { 229 // Save device IDs for dev client. 230 await this.saveDevicesAsync(req); 231 232 // Read from headers 233 const options = this.getParsedHeaders(req); 234 const { body, version, headers } = await this._getManifestResponseAsync(options); 235 for (const [headerName, headerValue] of headers) { 236 res.setHeader(headerName, headerValue); 237 } 238 res.end(body); 239 240 // Log analytics 241 this.trackManifest(version ?? null); 242 } 243} 244