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