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 // Ensure the API Routes are included 67 environment: 'node', 68 }); 69 return getManifest({ fetchData: true }); 70 } 71 72 async getStaticPageAsync( 73 pathname: string, 74 { 75 mode, 76 }: { 77 mode: 'development' | 'production'; 78 } 79 ) { 80 const location = new URL(pathname, 'https://example.dev'); 81 82 const load = await getStaticPageContentsAsync(this.projectRoot, this.getDevServerUrl()!, { 83 minify: mode === 'production', 84 dev: mode !== 'production', 85 // Ensure the API Routes are included 86 environment: 'node', 87 }); 88 89 return await load(location); 90 } 91 92 protected async startImplementationAsync( 93 options: BundlerStartOptions 94 ): Promise<DevServerInstance> { 95 options.port = await this.resolvePortAsync(options); 96 this.urlCreator = this.getUrlCreator(options); 97 98 const parsedOptions = { 99 port: options.port, 100 maxWorkers: options.maxWorkers, 101 resetCache: options.resetDevServer, 102 103 // Use the unversioned metro config. 104 // TODO: Deprecate this property when expo-cli goes away. 105 unversioned: false, 106 }; 107 108 const { metro, server, middleware, messageSocket } = await instantiateMetroAsync( 109 this, 110 parsedOptions 111 ); 112 113 const manifestMiddleware = await this.getManifestMiddlewareAsync(options); 114 115 // We need the manifest handler to be the first middleware to run so our 116 // routes take precedence over static files. For example, the manifest is 117 // served from '/' and if the user has an index.html file in their project 118 // then the manifest handler will never run, the static middleware will run 119 // and serve index.html instead of the manifest. 120 // https://github.com/expo/expo/issues/13114 121 122 prependMiddleware(middleware, manifestMiddleware.getHandler()); 123 124 middleware.use( 125 new InterstitialPageMiddleware(this.projectRoot, { 126 // TODO: Prevent this from becoming stale. 127 scheme: options.location.scheme ?? null, 128 }).getHandler() 129 ); 130 middleware.use(new ReactDevToolsPageMiddleware(this.projectRoot).getHandler()); 131 132 const deepLinkMiddleware = new RuntimeRedirectMiddleware(this.projectRoot, { 133 onDeepLink: getDeepLinkHandler(this.projectRoot), 134 getLocation: ({ runtime }) => { 135 if (runtime === 'custom') { 136 return this.urlCreator?.constructDevClientUrl(); 137 } else { 138 return this.urlCreator?.constructUrl({ 139 scheme: 'exp', 140 }); 141 } 142 }, 143 }); 144 middleware.use(deepLinkMiddleware.getHandler()); 145 146 middleware.use(new CreateFileMiddleware(this.projectRoot).getHandler()); 147 148 // Append support for redirecting unhandled requests to the index.html page on web. 149 if (this.isTargetingWeb()) { 150 // This MUST be after the manifest middleware so it doesn't have a chance to serve the template `public/index.html`. 151 middleware.use(new ServeStaticMiddleware(this.projectRoot).getHandler()); 152 153 const devServerUrl = `http://localhost:${options.port}`; 154 155 if (env.EXPO_USE_STATIC) { 156 middleware.use(async (req: ServerRequest, res: ServerResponse, next: ServerNext) => { 157 if (!req?.url) { 158 return next(); 159 } 160 161 // TODO: Formal manifest for allowed paths 162 if (req.url.endsWith('.ico')) { 163 return next(); 164 } 165 166 const location = new URL(req.url, devServerUrl); 167 168 try { 169 const { getStaticContent } = await getStaticRenderFunctions( 170 this.projectRoot, 171 devServerUrl, 172 { 173 minify: options.mode === 'production', 174 dev: options.mode !== 'production', 175 // Ensure the API Routes are included 176 environment: 'node', 177 } 178 ); 179 180 let content = await getStaticContent(location); 181 182 //TODO: Not this -- disable injection some other way 183 if (options.mode !== 'production') { 184 // Add scripts for rehydration 185 // TODO: bundle split 186 content = content.replace( 187 '</body>', 188 [`<script src="${manifestMiddleware.getWebBundleUrl()}" defer></script>`].join( 189 '\n' 190 ) + '</body>' 191 ); 192 } 193 194 res.setHeader('Content-Type', 'text/html'); 195 res.end(content); 196 return; 197 } catch (error: any) { 198 console.error(error); 199 res.setHeader('Content-Type', 'text/html'); 200 res.end(getErrorResult(error)); 201 } 202 }); 203 } 204 205 // This MUST run last since it's the fallback. 206 if (!env.EXPO_USE_STATIC) { 207 middleware.use( 208 new HistoryFallbackMiddleware(manifestMiddleware.getHandler().internal).getHandler() 209 ); 210 } 211 } 212 // Extend the close method to ensure that we clean up the local info. 213 const originalClose = server.close.bind(server); 214 215 server.close = (callback?: (err?: Error) => void) => { 216 return originalClose((err?: Error) => { 217 this.instance = null; 218 this.metro = null; 219 callback?.(err); 220 }); 221 }; 222 223 this.metro = metro; 224 return { 225 server, 226 location: { 227 // The port is the main thing we want to send back. 228 port: options.port, 229 // localhost isn't always correct. 230 host: 'localhost', 231 // http is the only supported protocol on native. 232 url: `http://localhost:${options.port}`, 233 protocol: 'http', 234 }, 235 middleware, 236 messageSocket, 237 }; 238 } 239 240 public async waitForTypeScriptAsync(): Promise<void> { 241 if (!this.instance) { 242 throw new Error('Cannot wait for TypeScript without a running server.'); 243 } 244 if (!this.metro) { 245 // This can happen when the run command is used and the server is already running in another 246 // process. In this case we can't wait for the TypeScript check to complete because we don't 247 // have access to the Metro server. 248 debug('Skipping TypeScript check because Metro is not running (headless).'); 249 return; 250 } 251 252 const off = waitForMetroToObserveTypeScriptFile( 253 this.projectRoot, 254 { server: this.instance!.server, metro: this.metro }, 255 async () => { 256 // Run once, this prevents the TypeScript project prerequisite from running on every file change. 257 off(); 258 const { TypeScriptProjectPrerequisite } = await import( 259 '../../doctor/typescript/TypeScriptProjectPrerequisite' 260 ); 261 262 try { 263 const req = new TypeScriptProjectPrerequisite(this.projectRoot); 264 await req.bootstrapAsync(); 265 } catch (error: any) { 266 // Ensure the process doesn't fail if the TypeScript check fails. 267 // This could happen during the install. 268 Log.log(); 269 Log.error( 270 chalk.red`Failed to automatically setup TypeScript for your project. Try restarting the dev server to fix.` 271 ); 272 Log.exception(error); 273 } 274 } 275 ); 276 } 277 278 protected getConfigModuleIds(): string[] { 279 return ['./metro.config.js', './metro.config.json', './rn-cli.config.js']; 280 } 281} 282 283function getErrorResult(error: Error) { 284 return ` 285 <!DOCTYPE html> 286 <html lang="en"> 287 <head> 288 <meta charset="utf-8"> 289 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 290 <title>Error</title> 291 </head> 292 <body> 293 <h1>Failed to render static app</h1> 294 <pre>${error.stack}</pre> 295 </body> 296 </html> 297 `; 298} 299 300export function getDeepLinkHandler(projectRoot: string): DeepLinkHandler { 301 return async ({ runtime }) => { 302 if (runtime === 'expo') return; 303 const { exp } = getConfig(projectRoot); 304 await logEventAsync('dev client start command', { 305 status: 'started', 306 ...getDevClientProperties(projectRoot, exp), 307 }); 308 }; 309} 310