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