1/** 2 * Copyright © 2022 650 Industries. 3 * 4 * This source code is licensed under the MIT license found in the 5 * LICENSE file in the root directory of this source tree. 6 */ 7import { getConfig } from '@expo/config'; 8import { prependMiddleware } from '@expo/dev-server'; 9import assert from 'assert'; 10import chalk from 'chalk'; 11 12import { Log } from '../../../log'; 13import getDevClientProperties from '../../../utils/analytics/getDevClientProperties'; 14import { logEventAsync } from '../../../utils/analytics/rudderstackClient'; 15import { env } from '../../../utils/env'; 16import { getFreePortAsync } from '../../../utils/port'; 17import { BundlerDevServer, BundlerStartOptions, DevServerInstance } from '../BundlerDevServer'; 18import { getStaticRenderFunctions, getStaticPageContentsAsync } from '../getStaticRenderFunctions'; 19import { CreateFileMiddleware } from '../middleware/CreateFileMiddleware'; 20import { HistoryFallbackMiddleware } from '../middleware/HistoryFallbackMiddleware'; 21import { InterstitialPageMiddleware } from '../middleware/InterstitialPageMiddleware'; 22import { ReactDevToolsPageMiddleware } from '../middleware/ReactDevToolsPageMiddleware'; 23import { 24 DeepLinkHandler, 25 RuntimeRedirectMiddleware, 26} from '../middleware/RuntimeRedirectMiddleware'; 27import { ServeStaticMiddleware } from '../middleware/ServeStaticMiddleware'; 28import { ServerNext, ServerRequest, ServerResponse } from '../middleware/server.types'; 29import { instantiateMetroAsync } from './instantiateMetro'; 30import { waitForMetroToObserveTypeScriptFile } from './waitForMetroToObserveTypeScriptFile'; 31 32const debug = require('debug')('expo:start:server:metro') as typeof console.log; 33 34/** Default port to use for apps running in Expo Go. */ 35const EXPO_GO_METRO_PORT = 19000; 36 37/** Default port to use for apps that run in standard React Native projects or Expo Dev Clients. */ 38const DEV_CLIENT_METRO_PORT = 8081; 39 40export class MetroBundlerDevServer extends BundlerDevServer { 41 private metro: import('metro').Server | null = null; 42 43 get name(): string { 44 return 'metro'; 45 } 46 47 async resolvePortAsync(options: Partial<BundlerStartOptions> = {}): Promise<number> { 48 const port = 49 // If the manually defined port is busy then an error should be thrown... 50 options.port ?? 51 // Otherwise use the default port based on the runtime target. 52 (options.devClient 53 ? // Don't check if the port is busy if we're using the dev client since most clients are hardcoded to 8081. 54 Number(process.env.RCT_METRO_PORT) || DEV_CLIENT_METRO_PORT 55 : // Otherwise (running in Expo Go) use a free port that falls back on the classic 19000 port. 56 await getFreePortAsync(EXPO_GO_METRO_PORT)); 57 58 return port; 59 } 60 61 /** Get routes from Expo Router. */ 62 async getRoutesAsync() { 63 const url = this.getDevServerUrl(); 64 assert(url, 'Dev server must be started'); 65 const { getManifest } = await getStaticRenderFunctions(this.projectRoot, url); 66 return getManifest({ fetchData: true }); 67 } 68 69 async getStaticPageAsync( 70 pathname: string, 71 { 72 mode, 73 }: { 74 mode: 'development' | 'production'; 75 } 76 ) { 77 const location = new URL(pathname, 'https://example.dev'); 78 79 const load = await getStaticPageContentsAsync(this.projectRoot, this.getDevServerUrl()!, { 80 minify: mode === 'production', 81 dev: mode !== 'production', 82 }); 83 84 return await load(location); 85 } 86 87 protected async startImplementationAsync( 88 options: BundlerStartOptions 89 ): Promise<DevServerInstance> { 90 options.port = await this.resolvePortAsync(options); 91 this.urlCreator = this.getUrlCreator(options); 92 93 const parsedOptions = { 94 port: options.port, 95 maxWorkers: options.maxWorkers, 96 resetCache: options.resetDevServer, 97 98 // Use the unversioned metro config. 99 // TODO: Deprecate this property when expo-cli goes away. 100 unversioned: false, 101 }; 102 103 const { metro, server, middleware, messageSocket } = await instantiateMetroAsync( 104 this.projectRoot, 105 parsedOptions 106 ); 107 108 const manifestMiddleware = await this.getManifestMiddlewareAsync(options); 109 110 // We need the manifest handler to be the first middleware to run so our 111 // routes take precedence over static files. For example, the manifest is 112 // served from '/' and if the user has an index.html file in their project 113 // then the manifest handler will never run, the static middleware will run 114 // and serve index.html instead of the manifest. 115 // https://github.com/expo/expo/issues/13114 116 117 prependMiddleware(middleware, manifestMiddleware.getHandler()); 118 119 middleware.use( 120 new InterstitialPageMiddleware(this.projectRoot, { 121 // TODO: Prevent this from becoming stale. 122 scheme: options.location.scheme ?? null, 123 }).getHandler() 124 ); 125 middleware.use(new ReactDevToolsPageMiddleware(this.projectRoot).getHandler()); 126 127 const deepLinkMiddleware = new RuntimeRedirectMiddleware(this.projectRoot, { 128 onDeepLink: getDeepLinkHandler(this.projectRoot), 129 getLocation: ({ runtime }) => { 130 if (runtime === 'custom') { 131 return this.urlCreator?.constructDevClientUrl(); 132 } else { 133 return this.urlCreator?.constructUrl({ 134 scheme: 'exp', 135 }); 136 } 137 }, 138 }); 139 middleware.use(deepLinkMiddleware.getHandler()); 140 141 middleware.use(new CreateFileMiddleware(this.projectRoot).getHandler()); 142 143 // Append support for redirecting unhandled requests to the index.html page on web. 144 if (this.isTargetingWeb()) { 145 // This MUST be after the manifest middleware so it doesn't have a chance to serve the template `public/index.html`. 146 middleware.use(new ServeStaticMiddleware(this.projectRoot).getHandler()); 147 148 const devServerUrl = `http://localhost:${options.port}`; 149 150 if (env.EXPO_USE_STATIC) { 151 middleware.use(async (req: ServerRequest, res: ServerResponse, next: ServerNext) => { 152 if (!req?.url) { 153 return next(); 154 } 155 156 // TODO: Formal manifest for allowed paths 157 if (req.url.endsWith('.ico')) { 158 return next(); 159 } 160 161 const location = new URL(req.url, devServerUrl); 162 163 try { 164 const { getStaticContent } = await getStaticRenderFunctions( 165 this.projectRoot, 166 devServerUrl, 167 { 168 minify: options.mode === 'production', 169 dev: options.mode !== 'production', 170 } 171 ); 172 173 let content = await getStaticContent(location); 174 175 //TODO: Not this -- disable injection some other way 176 if (options.mode !== 'production') { 177 // Add scripts for rehydration 178 // TODO: bundle split 179 content = content.replace( 180 '</body>', 181 [`<script src="${manifestMiddleware.getWebBundleUrl()}" defer></script>`].join( 182 '\n' 183 ) + '</body>' 184 ); 185 } 186 187 res.setHeader('Content-Type', 'text/html'); 188 res.end(content); 189 return; 190 } catch (error: any) { 191 console.error(error); 192 res.setHeader('Content-Type', 'text/html'); 193 res.end(getErrorResult(error)); 194 } 195 }); 196 } 197 198 // This MUST run last since it's the fallback. 199 if (!env.EXPO_USE_STATIC) { 200 middleware.use( 201 new HistoryFallbackMiddleware(manifestMiddleware.getHandler().internal).getHandler() 202 ); 203 } 204 } 205 // Extend the close method to ensure that we clean up the local info. 206 const originalClose = server.close.bind(server); 207 208 server.close = (callback?: (err?: Error) => void) => { 209 return originalClose((err?: Error) => { 210 this.instance = null; 211 this.metro = null; 212 callback?.(err); 213 }); 214 }; 215 216 this.metro = metro; 217 return { 218 server, 219 location: { 220 // The port is the main thing we want to send back. 221 port: options.port, 222 // localhost isn't always correct. 223 host: 'localhost', 224 // http is the only supported protocol on native. 225 url: `http://localhost:${options.port}`, 226 protocol: 'http', 227 }, 228 middleware, 229 messageSocket, 230 }; 231 } 232 233 public async waitForTypeScriptAsync(): Promise<void> { 234 if (!this.instance) { 235 throw new Error('Cannot wait for TypeScript without a running server.'); 236 } 237 if (!this.metro) { 238 // This can happen when the run command is used and the server is already running in another 239 // process. In this case we can't wait for the TypeScript check to complete because we don't 240 // have access to the Metro server. 241 debug('Skipping TypeScript check because Metro is not running (headless).'); 242 return; 243 } 244 245 const off = waitForMetroToObserveTypeScriptFile( 246 this.projectRoot, 247 { server: this.instance!.server, metro: this.metro }, 248 async () => { 249 // Run once, this prevents the TypeScript project prerequisite from running on every file change. 250 off(); 251 const { TypeScriptProjectPrerequisite } = await import( 252 '../../doctor/typescript/TypeScriptProjectPrerequisite' 253 ); 254 255 try { 256 const req = new TypeScriptProjectPrerequisite(this.projectRoot); 257 await req.bootstrapAsync(); 258 } catch (error: any) { 259 // Ensure the process doesn't fail if the TypeScript check fails. 260 // This could happen during the install. 261 Log.log(); 262 Log.error( 263 chalk.red`Failed to automatically setup TypeScript for your project. Try restarting the dev server to fix.` 264 ); 265 Log.exception(error); 266 } 267 } 268 ); 269 } 270 271 protected getConfigModuleIds(): string[] { 272 return ['./metro.config.js', './metro.config.json', './rn-cli.config.js']; 273 } 274} 275 276function getErrorResult(error: Error) { 277 return ` 278 <!DOCTYPE html> 279 <html lang="en"> 280 <head> 281 <meta charset="utf-8"> 282 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 283 <title>Error</title> 284 </head> 285 <body> 286 <h1>Failed to render static app</h1> 287 <pre>${error.stack}</pre> 288 </body> 289 </html> 290 `; 291} 292 293export function getDeepLinkHandler(projectRoot: string): DeepLinkHandler { 294 return async ({ runtime }) => { 295 if (runtime === 'expo') return; 296 const { exp } = getConfig(projectRoot); 297 await logEventAsync('dev client start command', { 298 status: 'started', 299 ...getDevClientProperties(projectRoot, exp), 300 }); 301 }; 302} 303