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