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