1import { ExpoUpdatesManifest } from '@expo/config';
2import { Updates } from '@expo/config-plugins';
3import accepts from 'accepts';
4import assert from 'assert';
5import FormData from 'form-data';
6import { v4 as uuidv4 } from 'uuid';
7
8import { getProjectAsync } from '../../../api/getProject';
9import { APISettings } from '../../../api/settings';
10import { signExpoGoManifestAsync } from '../../../api/signManifest';
11import UserSettings from '../../../api/user/UserSettings';
12import { ANONYMOUS_USERNAME, getUserAsync } from '../../../api/user/user';
13import { logEvent } from '../../../utils/analytics/rudderstackClient';
14import { CommandError } from '../../../utils/errors';
15import { memoize } from '../../../utils/fn';
16import { stripPort } from '../../../utils/url';
17import { ManifestMiddleware, ManifestRequestInfo } from './ManifestMiddleware';
18import {
19  assertMissingRuntimePlatform,
20  assertRuntimePlatform,
21  parsePlatformHeader,
22} from './resolvePlatform';
23import { ServerHeaders, ServerRequest } from './server.types';
24
25interface ExpoGoManifestRequestInfo extends ManifestRequestInfo {
26  explicitlyPrefersMultipartMixed: boolean;
27}
28
29export class ExpoGoManifestHandlerMiddleware extends ManifestMiddleware<ExpoGoManifestRequestInfo> {
30  public getParsedHeaders(req: ServerRequest): ExpoGoManifestRequestInfo {
31    const platform = parsePlatformHeader(req);
32    assertMissingRuntimePlatform(platform);
33    assertRuntimePlatform(platform);
34
35    // Expo Updates clients explicitly accept "multipart/mixed" responses while browsers implicitly
36    // accept them with "accept: */*". To make it easier to debug manifest responses by visiting their
37    // URLs in a browser, we denote the response as "text/plain" if the user agent appears not to be
38    // an Expo Updates client.
39    const accept = accepts(req);
40    const explicitlyPrefersMultipartMixed =
41      accept.types(['unknown/unknown', 'multipart/mixed']) === 'multipart/mixed';
42
43    return {
44      explicitlyPrefersMultipartMixed,
45      platform,
46      acceptSignature: !!req.headers['expo-accept-signature'],
47      hostname: stripPort(req.headers['host']),
48    };
49  }
50
51  private getDefaultResponseHeaders(): ServerHeaders {
52    const headers = new Map<string, number | string | readonly string[]>();
53    // set required headers for Expo Updates manifest specification
54    headers.set('expo-protocol-version', 0);
55    headers.set('expo-sfv-version', 0);
56    headers.set('cache-control', 'private, max-age=0');
57    return headers;
58  }
59
60  public async _getManifestResponseAsync(requestOptions: ExpoGoManifestRequestInfo): Promise<{
61    body: string;
62    version: string;
63    headers: ServerHeaders;
64  }> {
65    const { exp, hostUri, expoGoConfig, bundleUrl } = await this._resolveProjectSettingsAsync(
66      requestOptions
67    );
68
69    const runtimeVersion = Updates.getRuntimeVersion(
70      { ...exp, runtimeVersion: exp.runtimeVersion ?? { policy: 'sdkVersion' } },
71      requestOptions.platform
72    );
73    if (!runtimeVersion) {
74      throw new CommandError(
75        'MANIFEST_MIDDLEWARE',
76        `Unable to determine runtime version for platform '${requestOptions.platform}'`
77      );
78    }
79
80    const easProjectId = exp.extra?.eas?.projectId;
81    const shouldUseAnonymousManifest = await shouldUseAnonymousManifestAsync(easProjectId);
82    const userAnonymousIdentifier = await UserSettings.getAnonymousIdentifierAsync();
83    if (!shouldUseAnonymousManifest) {
84      assert(easProjectId);
85    }
86    const scopeKey = shouldUseAnonymousManifest
87      ? `@${ANONYMOUS_USERNAME}/${exp.slug}-${userAnonymousIdentifier}`
88      : await this.getScopeKeyForProjectIdAsync(easProjectId);
89
90    const expoUpdatesManifest: ExpoUpdatesManifest = {
91      id: uuidv4(),
92      createdAt: new Date().toISOString(),
93      runtimeVersion,
94      launchAsset: {
95        key: 'bundle',
96        contentType: 'application/javascript',
97        url: bundleUrl,
98      },
99      assets: [], // assets are not used in development
100      metadata: {}, // required for the client to detect that this is an expo-updates manifest
101      extra: {
102        eas: {
103          projectId: easProjectId ?? undefined,
104        },
105        expoClient: {
106          ...exp,
107          hostUri,
108        },
109        expoGo: expoGoConfig,
110        scopeKey,
111      },
112    };
113
114    const headers = this.getDefaultResponseHeaders();
115    if (requestOptions.acceptSignature && !shouldUseAnonymousManifest) {
116      const manifestSignature = await this.getSignedManifestStringAsync(expoUpdatesManifest);
117      headers.set('expo-manifest-signature', manifestSignature);
118    }
119
120    const form = this.getFormData({
121      stringifiedManifest: JSON.stringify(expoUpdatesManifest),
122    });
123
124    headers.set(
125      'content-type',
126      requestOptions.explicitlyPrefersMultipartMixed
127        ? `multipart/mixed; boundary=${form.getBoundary()}`
128        : 'text/plain'
129    );
130
131    return {
132      body: form.getBuffer().toString(),
133      version: runtimeVersion,
134      headers,
135    };
136  }
137
138  private getFormData({ stringifiedManifest }: { stringifiedManifest: string }): FormData {
139    const form = new FormData();
140    form.append('manifest', stringifiedManifest, {
141      contentType: 'application/json',
142    });
143    return form;
144  }
145
146  protected trackManifest(version?: string) {
147    logEvent('Serve Expo Updates Manifest', {
148      runtimeVersion: version,
149    });
150  }
151
152  private getSignedManifestStringAsync = memoize(signExpoGoManifestAsync);
153
154  private getScopeKeyForProjectIdAsync = memoize(getScopeKeyForProjectIdAsync);
155}
156
157/**
158 * Whether an anonymous scope key should be used. It should be used when:
159 * 1. Offline
160 * 2. Not logged-in
161 * 3. No EAS project ID in config
162 */
163async function shouldUseAnonymousManifestAsync(
164  easProjectId: string | undefined | null
165): Promise<boolean> {
166  if (!easProjectId || APISettings.isOffline) {
167    return true;
168  }
169
170  return !(await getUserAsync());
171}
172
173async function getScopeKeyForProjectIdAsync(projectId: string): Promise<string> {
174  const project = await getProjectAsync(projectId);
175  return project.scopeKey;
176}
177