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