1import { ExpoConfig, getConfig } from '@expo/config'; 2import { MetroDevServerOptions, prependMiddleware } from '@expo/dev-server'; 3import type { LoadOptions } from '@expo/metro-config'; 4import chalk from 'chalk'; 5import http from 'http'; 6import type Metro from 'metro'; 7import { Terminal } from 'metro-core'; 8import semver from 'semver'; 9import { URL } from 'url'; 10 11import { MetroBundlerDevServer } from './MetroBundlerDevServer'; 12import { MetroTerminalReporter } from './MetroTerminalReporter'; 13import { importExpoMetroConfig } from './resolveFromProject'; 14import { getRouterDirectory } from './router'; 15import { runServer } from './runServer-fork'; 16import { withMetroMultiPlatformAsync } from './withMetroMultiPlatform'; 17import { Log } from '../../../log'; 18import { getMetroProperties } from '../../../utils/analytics/getMetroProperties'; 19import { createDebuggerTelemetryMiddleware } from '../../../utils/analytics/metroDebuggerMiddleware'; 20import { logEventAsync } from '../../../utils/analytics/rudderstackClient'; 21import { env } from '../../../utils/env'; 22import { getMetroServerRoot } from '../middleware/ManifestMiddleware'; 23import { createDevServerMiddleware } from '../middleware/createDevServerMiddleware'; 24import { ServerNext, ServerRequest, ServerResponse } from '../middleware/server.types'; 25import { getPlatformBundlers } from '../platformBundlers'; 26 27// From expo/dev-server but with ability to use custom logger. 28type MessageSocket = { 29 broadcast: (method: string, params?: Record<string, any> | undefined) => void; 30}; 31 32function gteSdkVersion(exp: Pick<ExpoConfig, 'sdkVersion'>, sdkVersion: string): boolean { 33 if (!exp.sdkVersion) { 34 return false; 35 } 36 37 if (exp.sdkVersion === 'UNVERSIONED') { 38 return true; 39 } 40 41 try { 42 return semver.gte(exp.sdkVersion, sdkVersion); 43 } catch { 44 throw new Error(`${exp.sdkVersion} is not a valid version. Must be in the form of x.y.z`); 45 } 46} 47 48export async function loadMetroConfigAsync( 49 projectRoot: string, 50 options: LoadOptions, 51 { 52 exp = getConfig(projectRoot, { skipSDKVersionRequirement: true, skipPlugins: true }).exp, 53 isExporting, 54 }: { exp?: ExpoConfig; isExporting: boolean } 55) { 56 let reportEvent: ((event: any) => void) | undefined; 57 const serverRoot = getMetroServerRoot(projectRoot); 58 59 const terminal = new Terminal(process.stdout); 60 const terminalReporter = new MetroTerminalReporter(serverRoot, terminal); 61 62 const reporter = { 63 update(event: any) { 64 terminalReporter.update(event); 65 if (reportEvent) { 66 reportEvent(event); 67 } 68 }, 69 }; 70 71 const ExpoMetroConfig = importExpoMetroConfig(projectRoot); 72 let config = await ExpoMetroConfig.loadAsync(projectRoot, { reporter, ...options }); 73 74 if ( 75 // Requires SDK 50 for expo-assets hashAssetPlugin change. 76 !exp.sdkVersion || 77 gteSdkVersion(exp, '50.0.0') 78 ) { 79 // TODO: Handle asset prefix. 80 if (isExporting) { 81 // This token will be used in the asset plugin to ensure the path is correct for writing locally. 82 // @ts-expect-error: typed as readonly. 83 config.transformer.publicPath = '/assets?export_path=/assets'; 84 } else { 85 // @ts-expect-error: typed as readonly 86 config.transformer.publicPath = '/assets/?unstable_path=.'; 87 } 88 } 89 90 const platformBundlers = getPlatformBundlers(exp); 91 92 config = await withMetroMultiPlatformAsync(projectRoot, { 93 routerDirectory: exp.extra?.router?.unstable_src ?? getRouterDirectory(projectRoot), 94 config, 95 platformBundlers, 96 isTsconfigPathsEnabled: !!exp.experiments?.tsconfigPaths, 97 webOutput: exp.web?.output ?? 'single', 98 }); 99 100 logEventAsync('metro config', getMetroProperties(projectRoot, exp, config)); 101 102 return { 103 config, 104 setEventReporter: (logger: (event: any) => void) => (reportEvent = logger), 105 reporter: terminalReporter, 106 }; 107} 108 109/** The most generic possible setup for Metro bundler. */ 110export async function instantiateMetroAsync( 111 metroBundler: MetroBundlerDevServer, 112 options: Omit<MetroDevServerOptions, 'logger'>, 113 { isExporting }: { isExporting: boolean } 114): Promise<{ 115 metro: Metro.Server; 116 server: http.Server; 117 middleware: any; 118 messageSocket: MessageSocket; 119}> { 120 const projectRoot = metroBundler.projectRoot; 121 122 // TODO: When we bring expo/metro-config into the expo/expo repo, then we can upstream this. 123 const { exp } = getConfig(projectRoot, { 124 skipSDKVersionRequirement: true, 125 skipPlugins: true, 126 }); 127 128 const { config: metroConfig, setEventReporter } = await loadMetroConfigAsync( 129 projectRoot, 130 options, 131 { exp, isExporting } 132 ); 133 134 const { middleware, websocketEndpoints, eventsSocketEndpoint, messageSocketEndpoint } = 135 createDevServerMiddleware(projectRoot, { 136 port: metroConfig.server.port, 137 watchFolders: metroConfig.watchFolders, 138 }); 139 140 const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware; 141 // @ts-expect-error: can't mutate readonly config 142 metroConfig.server.enhanceMiddleware = (metroMiddleware: any, server: Metro.Server) => { 143 if (customEnhanceMiddleware) { 144 metroMiddleware = customEnhanceMiddleware(metroMiddleware, server); 145 } 146 return middleware.use(metroMiddleware); 147 }; 148 149 middleware.use(createDebuggerTelemetryMiddleware(projectRoot, exp)); 150 151 const { server, metro } = await runServer(metroBundler, metroConfig, { 152 hmrEnabled: true, 153 // @ts-expect-error: Inconsistent `websocketEndpoints` type between metro and @react-native-community/cli-server-api 154 websocketEndpoints, 155 watch: isWatchEnabled(), 156 }); 157 158 prependMiddleware(middleware, (req: ServerRequest, res: ServerResponse, next: ServerNext) => { 159 // If the URL is a Metro asset request, then we need to skip all other middleware to prevent 160 // the community CLI's serve-static from hosting `/assets/index.html` in place of all assets if it exists. 161 // /assets/?unstable_path=. 162 if (req.url) { 163 const url = new URL(req.url!, 'http://localhost:8000'); 164 if (url.pathname.match(/^\/assets\/?/) && url.searchParams.get('unstable_path') != null) { 165 return metro.processRequest(req, res, next); 166 } 167 } 168 return next(); 169 }); 170 171 setEventReporter(eventsSocketEndpoint.reportEvent); 172 173 return { 174 metro, 175 server, 176 middleware, 177 messageSocket: messageSocketEndpoint, 178 }; 179} 180 181/** 182 * Simplify and communicate if Metro is running without watching file updates,. 183 * Exposed for testing. 184 */ 185export function isWatchEnabled() { 186 if (env.CI) { 187 Log.log( 188 chalk`Metro is running in CI mode, reloads are disabled. Remove {bold CI=true} to enable watch mode.` 189 ); 190 } 191 192 return !env.CI; 193} 194