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