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