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