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 path = this._getBundleUrlPath({ platform, mainModuleName });
144
145    return (
146      this.options.constructUrl({
147        scheme: 'http',
148        // hostType: this.options.location.hostType,
149        hostname,
150      }) + path
151    );
152  }
153
154  public _getBundleUrlPath({
155    platform,
156    mainModuleName,
157  }: {
158    platform: string;
159    mainModuleName: string;
160  }): string {
161    const queryParams = new URLSearchParams({
162      platform: encodeURIComponent(platform),
163      dev: String(this.options.mode !== 'production'),
164      // TODO: Is this still needed?
165      hot: String(false),
166    });
167
168    if (this.options.minify) {
169      queryParams.append('minify', String(this.options.minify));
170    }
171
172    return `/${encodeURI(mainModuleName)}.bundle?${queryParams.toString()}`;
173  }
174
175  /** Log telemetry. */
176  protected abstract trackManifest(version?: string): void;
177
178  /** Get the manifest response to return to the runtime. This file contains info regarding where the assets can be loaded from. Exposed for testing. */
179  public abstract _getManifestResponseAsync(options: TManifestRequestInfo): Promise<{
180    body: string;
181    version: string;
182    headers: ServerHeaders;
183  }>;
184
185  private getExpoGoConfig({
186    mainModuleName,
187    hostname,
188  }: {
189    mainModuleName: string;
190    hostname?: string | null;
191  }): ExpoGoConfig {
192    return {
193      // localhost:19000
194      debuggerHost: this.options.constructUrl({ scheme: '', hostname }),
195      // http://localhost:19000/logs -- used to send logs to the CLI for displaying in the terminal.
196      // This is deprecated in favor of the WebSocket connection setup in Metro.
197      logUrl: this.options.constructUrl({ scheme: 'http', hostname }) + '/logs',
198      // Required for Expo Go to function.
199      developer: {
200        tool: DEVELOPER_TOOL,
201        projectRoot: this.projectRoot,
202      },
203      packagerOpts: {
204        // Required for dev client.
205        dev: this.options.mode !== 'production',
206      },
207      // Indicates the name of the main bundle.
208      mainModuleName,
209      // Add this string to make Flipper register React Native / Metro as "running".
210      // Can be tested by running:
211      // `METRO_SERVER_PORT=19000 open -a flipper.app`
212      // Where 19000 is the port where the Expo project is being hosted.
213      __flipperHack: 'React Native packager is running',
214    };
215  }
216
217  /** Resolve all assets and set them on the manifest as URLs */
218  private async mutateManifestWithAssetsAsync(manifest: ExpoConfig, bundleUrl: string) {
219    await resolveManifestAssets(this.projectRoot, {
220      manifest,
221      resolver: async (path) => {
222        if (this.options.isNativeWebpack) {
223          // When using our custom dev server, just do assets normally
224          // without the `assets/` subpath redirect.
225          return resolve(bundleUrl!.match(/^https?:\/\/.*?\//)![0], path);
226        }
227        return bundleUrl!.match(/^https?:\/\/.*?\//)![0] + 'assets/' + path;
228      },
229    });
230    // The server normally inserts this but if we're offline we'll do it here
231    await resolveGoogleServicesFile(this.projectRoot, manifest);
232  }
233
234  async handleRequestAsync(
235    req: ServerRequest,
236    res: ServerResponse,
237    next: ServerNext
238  ): Promise<void> {
239    // Save device IDs for dev client.
240    await this.saveDevicesAsync(req);
241
242    // Read from headers
243    const options = this.getParsedHeaders(req);
244    const { body, version, headers } = await this._getManifestResponseAsync(options);
245    for (const [headerName, headerValue] of headers) {
246      res.setHeader(headerName, headerValue);
247    }
248    res.end(body);
249
250    // Log analytics
251    this.trackManifest(version ?? null);
252  }
253}
254