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