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