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 * as Log from '../../../log';
8import { env } from '../../../utils/env';
9import { stripExtension } from '../../../utils/url';
10import * as ProjectDevices from '../../project/devices';
11import { UrlCreator } from '../UrlCreator';
12import { getPlatformBundlers } from '../platformBundlers';
13import { createTemplateHtmlFromExpoConfigAsync } from '../webTemplate';
14import { ExpoMiddleware } from './ExpoMiddleware';
15import { resolveGoogleServicesFile, resolveManifestAssets } from './resolveAssets';
16import { resolveAbsoluteEntryPoint } from './resolveEntryPoint';
17import { parsePlatformHeader, RuntimePlatform } from './resolvePlatform';
18import { ServerHeaders, ServerNext, ServerRequest, ServerResponse } from './server.types';
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(protected projectRoot: string, protected options: ManifestMiddlewareOptions) {
165    super(
166      projectRoot,
167      /**
168       * Only support `/`, `/manifest`, `/index.exp` for the manifest middleware.
169       */
170      ['/', '/manifest', '/index.exp']
171    );
172    this.initialProjectConfig = getConfig(projectRoot);
173  }
174
175  /** Exposed for testing. */
176  public async _resolveProjectSettingsAsync({
177    platform,
178    hostname,
179  }: Pick<TManifestRequestInfo, 'hostname' | 'platform'>): Promise<ResponseProjectSettings> {
180    // Read the config
181    const projectConfig = getConfig(this.projectRoot);
182
183    // Read from headers
184    const mainModuleName = this.resolveMainModuleName(projectConfig, platform);
185
186    // Create the manifest and set fields within it
187    const expoGoConfig = this.getExpoGoConfig({
188      mainModuleName,
189      hostname,
190    });
191
192    const hostUri = this.options.constructUrl({ scheme: '', hostname });
193
194    const bundleUrl = this._getBundleUrl({
195      platform,
196      mainModuleName,
197      hostname,
198    });
199
200    // Resolve all assets and set them on the manifest as URLs
201    await this.mutateManifestWithAssetsAsync(projectConfig.exp, bundleUrl);
202
203    return {
204      expoGoConfig,
205      hostUri,
206      bundleUrl,
207      exp: projectConfig.exp,
208    };
209  }
210
211  /** Get the main entry module ID (file) relative to the project root. */
212  private resolveMainModuleName(projectConfig: ProjectConfig, platform: string): string {
213    let entryPoint = getEntryWithServerRoot(this.projectRoot, projectConfig, platform);
214
215    debug(`Resolved entry point: ${entryPoint} (project root: ${this.projectRoot})`);
216
217    // NOTE(Bacon): Webpack is currently hardcoded to index.bundle on native
218    // in the future (TODO) we should move this logic into a Webpack plugin and use
219    // a generated file name like we do on web.
220    // const server = getDefaultDevServer();
221    // // TODO: Move this into BundlerDevServer and read this info from self.
222    // const isNativeWebpack = server instanceof WebpackBundlerDevServer && server.isTargetingNative();
223    if (this.options.isNativeWebpack) {
224      entryPoint = 'index.js';
225    }
226
227    return stripExtension(entryPoint, 'js');
228  }
229
230  /** Parse request headers into options. */
231  public abstract getParsedHeaders(req: ServerRequest): TManifestRequestInfo;
232
233  /** Store device IDs that were sent in the request headers. */
234  private async saveDevicesAsync(req: ServerRequest) {
235    const deviceIds = req.headers?.['expo-dev-client-id'];
236    if (deviceIds) {
237      await ProjectDevices.saveDevicesAsync(this.projectRoot, deviceIds).catch((e) =>
238        Log.exception(e)
239      );
240    }
241  }
242
243  /** Create the bundle URL (points to the single JS entry file). Exposed for testing. */
244  public _getBundleUrl({
245    platform,
246    mainModuleName,
247    hostname,
248  }: {
249    platform: string;
250    hostname?: string | null;
251    mainModuleName: string;
252  }): string {
253    const path = createBundleUrlPath({
254      mode: this.options.mode ?? 'development',
255      minify: this.options.minify,
256      platform,
257      mainModuleName,
258      lazy: shouldEnableAsyncImports(this.projectRoot),
259    });
260
261    return (
262      this.options.constructUrl({
263        scheme: 'http',
264        // hostType: this.options.location.hostType,
265        hostname,
266      }) + path
267    );
268  }
269
270  public _getBundleUrlPath({
271    platform,
272    mainModuleName,
273  }: {
274    platform: string;
275    mainModuleName: string;
276  }): string {
277    const queryParams = new URLSearchParams({
278      platform: encodeURIComponent(platform),
279      dev: String(this.options.mode !== 'production'),
280      // TODO: Is this still needed?
281      hot: String(false),
282    });
283    if (shouldEnableAsyncImports(this.projectRoot)) {
284      queryParams.append('lazy', String(true));
285    }
286
287    if (this.options.minify) {
288      queryParams.append('minify', String(this.options.minify));
289    }
290
291    return `/${encodeURI(mainModuleName)}.bundle?${queryParams.toString()}`;
292  }
293
294  /** Log telemetry. */
295  protected abstract trackManifest(version?: string): void;
296
297  /** Get the manifest response to return to the runtime. This file contains info regarding where the assets can be loaded from. Exposed for testing. */
298  public abstract _getManifestResponseAsync(options: TManifestRequestInfo): Promise<{
299    body: string;
300    version: string;
301    headers: ServerHeaders;
302  }>;
303
304  private getExpoGoConfig({
305    mainModuleName,
306    hostname,
307  }: {
308    mainModuleName: string;
309    hostname?: string | null;
310  }): ExpoGoConfig {
311    return {
312      // localhost:8081
313      debuggerHost: this.options.constructUrl({ scheme: '', hostname }),
314      // Required for Expo Go to function.
315      developer: {
316        tool: DEVELOPER_TOOL,
317        projectRoot: this.projectRoot,
318      },
319      packagerOpts: {
320        // Required for dev client.
321        dev: this.options.mode !== 'production',
322      },
323      // Indicates the name of the main bundle.
324      mainModuleName,
325      // Add this string to make Flipper register React Native / Metro as "running".
326      // Can be tested by running:
327      // `METRO_SERVER_PORT=8081 open -a flipper.app`
328      // Where 8081 is the port where the Expo project is being hosted.
329      __flipperHack: 'React Native packager is running',
330    };
331  }
332
333  /** Resolve all assets and set them on the manifest as URLs */
334  private async mutateManifestWithAssetsAsync(manifest: ExpoConfig, bundleUrl: string) {
335    await resolveManifestAssets(this.projectRoot, {
336      manifest,
337      resolver: async (path) => {
338        if (this.options.isNativeWebpack) {
339          // When using our custom dev server, just do assets normally
340          // without the `assets/` subpath redirect.
341          return resolve(bundleUrl!.match(/^https?:\/\/.*?\//)![0], path);
342        }
343        return bundleUrl!.match(/^https?:\/\/.*?\//)![0] + 'assets/' + path;
344      },
345    });
346    // The server normally inserts this but if we're offline we'll do it here
347    await resolveGoogleServicesFile(this.projectRoot, manifest);
348  }
349
350  public getWebBundleUrl() {
351    const platform = 'web';
352    // Read from headers
353    const mainModuleName = this.resolveMainModuleName(this.initialProjectConfig, platform);
354    return this._getBundleUrlPath({
355      platform,
356      mainModuleName,
357    });
358  }
359
360  /**
361   * Web platforms should create an index.html response using the same script resolution as native.
362   *
363   * Instead of adding a `bundleUrl` to a `manifest.json` (native) we'll add a `<script src="">`
364   * to an `index.html`, this enables the web platform to load JavaScript from the server.
365   */
366  private async handleWebRequestAsync(req: ServerRequest, res: ServerResponse) {
367    // Read from headers
368    const bundleUrl = this.getWebBundleUrl();
369
370    res.setHeader('Content-Type', 'text/html');
371
372    res.end(
373      await createTemplateHtmlFromExpoConfigAsync(this.projectRoot, {
374        exp: this.initialProjectConfig.exp,
375        scripts: [bundleUrl],
376      })
377    );
378  }
379
380  /** Exposed for testing. */
381  async checkBrowserRequestAsync(req: ServerRequest, res: ServerResponse, next: ServerNext) {
382    // Read the config
383    const bundlers = getPlatformBundlers(this.initialProjectConfig.exp);
384    if (bundlers.web === 'metro') {
385      // NOTE(EvanBacon): This effectively disables the safety check we do on custom runtimes to ensure
386      // the `expo-platform` header is included. When `web.bundler=web`, if the user has non-standard Expo
387      // code loading then they'll get a web bundle without a clear assertion of platform support.
388      const platform = parsePlatformHeader(req);
389      // On web, serve the public folder
390      if (!platform || platform === 'web') {
391        if (this.initialProjectConfig.exp.web?.output === 'static') {
392          // Skip the spa-styled index.html when static generation is enabled.
393          next();
394          return true;
395        } else {
396          await this.handleWebRequestAsync(req, res);
397          return true;
398        }
399      }
400    }
401    return false;
402  }
403
404  async handleRequestAsync(
405    req: ServerRequest,
406    res: ServerResponse,
407    next: ServerNext
408  ): Promise<void> {
409    // First check for standard JavaScript runtimes (aka legacy browsers like Chrome).
410    if (await this.checkBrowserRequestAsync(req, res, next)) {
411      return;
412    }
413
414    // Save device IDs for dev client.
415    await this.saveDevicesAsync(req);
416
417    // Read from headers
418    const options = this.getParsedHeaders(req);
419    const { body, version, headers } = await this._getManifestResponseAsync(options);
420    for (const [headerName, headerValue] of headers) {
421      res.setHeader(headerName, headerValue);
422    }
423    res.end(body);
424
425    // Log analytics
426    this.trackManifest(version ?? null);
427  }
428}
429