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