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