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