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