1import { ExpoUpdatesManifest } from '@expo/config'; 2import { Updates } from '@expo/config-plugins'; 3import accepts from 'accepts'; 4import crypto from 'crypto'; 5import FormData from 'form-data'; 6import { serializeDictionary, Dictionary } from 'structured-headers'; 7 8import { ManifestMiddleware, ManifestRequestInfo } from './ManifestMiddleware'; 9import { assertRuntimePlatform, parsePlatformHeader } from './resolvePlatform'; 10import { ServerHeaders, ServerRequest } from './server.types'; 11import UserSettings from '../../../api/user/UserSettings'; 12import { ANONYMOUS_USERNAME } from '../../../api/user/user'; 13import { logEventAsync } from '../../../utils/analytics/rudderstackClient'; 14import { 15 CodeSigningInfo, 16 getCodeSigningInfoAsync, 17 signManifestString, 18} from '../../../utils/codesigning'; 19import { CommandError } from '../../../utils/errors'; 20import { stripPort } from '../../../utils/url'; 21 22const debug = require('debug')('expo:start:server:middleware:ExpoGoManifestHandlerMiddleware'); 23 24export enum ResponseContentType { 25 TEXT_PLAIN, 26 APPLICATION_JSON, 27 APPLICATION_EXPO_JSON, 28 MULTIPART_MIXED, 29} 30 31interface ExpoGoManifestRequestInfo extends ManifestRequestInfo { 32 responseContentType: ResponseContentType; 33 expectSignature: string | null; 34} 35 36export class ExpoGoManifestHandlerMiddleware extends ManifestMiddleware<ExpoGoManifestRequestInfo> { 37 public getParsedHeaders(req: ServerRequest): ExpoGoManifestRequestInfo { 38 let platform = parsePlatformHeader(req); 39 40 if (!platform) { 41 debug( 42 `No "expo-platform" header or "platform" query parameter specified. Falling back to "ios".` 43 ); 44 platform = 'ios'; 45 } 46 47 assertRuntimePlatform(platform); 48 49 // Expo Updates clients explicitly accept "multipart/mixed" responses while browsers implicitly 50 // accept them with "accept: */*". To make it easier to debug manifest responses by visiting their 51 // URLs in a browser, we denote the response as "text/plain" if the user agent appears not to be 52 // an Expo Updates client. 53 const accept = accepts(req); 54 const acceptedType = accept.types([ 55 'unknown/unknown', 56 'multipart/mixed', 57 'application/json', 58 'application/expo+json', 59 'text/plain', 60 ]); 61 62 let responseContentType; 63 switch (acceptedType) { 64 case 'multipart/mixed': 65 responseContentType = ResponseContentType.MULTIPART_MIXED; 66 break; 67 case 'application/json': 68 responseContentType = ResponseContentType.APPLICATION_JSON; 69 break; 70 case 'application/expo+json': 71 responseContentType = ResponseContentType.APPLICATION_EXPO_JSON; 72 break; 73 default: 74 responseContentType = ResponseContentType.TEXT_PLAIN; 75 break; 76 } 77 78 const expectSignature = req.headers['expo-expect-signature']; 79 80 return { 81 responseContentType, 82 platform, 83 expectSignature: expectSignature ? String(expectSignature) : null, 84 hostname: stripPort(req.headers['host']), 85 }; 86 } 87 88 private getDefaultResponseHeaders(): ServerHeaders { 89 const headers = new Map<string, number | string | readonly string[]>(); 90 // set required headers for Expo Updates manifest specification 91 headers.set('expo-protocol-version', 0); 92 headers.set('expo-sfv-version', 0); 93 headers.set('cache-control', 'private, max-age=0'); 94 return headers; 95 } 96 97 public async _getManifestResponseAsync(requestOptions: ExpoGoManifestRequestInfo): Promise<{ 98 body: string; 99 version: string; 100 headers: ServerHeaders; 101 }> { 102 const { exp, hostUri, expoGoConfig, bundleUrl } = 103 await this._resolveProjectSettingsAsync(requestOptions); 104 105 const runtimeVersion = await Updates.getRuntimeVersionAsync( 106 this.projectRoot, 107 { ...exp, runtimeVersion: exp.runtimeVersion ?? { policy: 'sdkVersion' } }, 108 requestOptions.platform 109 ); 110 if (!runtimeVersion) { 111 throw new CommandError( 112 'MANIFEST_MIDDLEWARE', 113 `Unable to determine runtime version for platform '${requestOptions.platform}'` 114 ); 115 } 116 117 const codeSigningInfo = await getCodeSigningInfoAsync( 118 exp, 119 requestOptions.expectSignature, 120 this.options.privateKeyPath 121 ); 122 123 const easProjectId = exp.extra?.eas?.projectId as string | undefined | null; 124 const scopeKey = await ExpoGoManifestHandlerMiddleware.getScopeKeyAsync({ 125 slug: exp.slug, 126 codeSigningInfo, 127 }); 128 129 const expoUpdatesManifest: ExpoUpdatesManifest = { 130 id: crypto.randomUUID(), 131 createdAt: new Date().toISOString(), 132 runtimeVersion, 133 launchAsset: { 134 key: 'bundle', 135 contentType: 'application/javascript', 136 url: bundleUrl, 137 }, 138 assets: [], // assets are not used in development 139 metadata: {}, // required for the client to detect that this is an expo-updates manifest 140 extra: { 141 eas: { 142 projectId: easProjectId ?? undefined, 143 }, 144 expoClient: { 145 ...exp, 146 hostUri, 147 }, 148 expoGo: expoGoConfig, 149 scopeKey, 150 }, 151 }; 152 153 const stringifiedManifest = JSON.stringify(expoUpdatesManifest); 154 155 let manifestPartHeaders: { 'expo-signature': string } | null = null; 156 let certificateChainBody: string | null = null; 157 if (codeSigningInfo) { 158 const signature = signManifestString(stringifiedManifest, codeSigningInfo); 159 manifestPartHeaders = { 160 'expo-signature': serializeDictionary( 161 convertToDictionaryItemsRepresentation({ 162 keyid: codeSigningInfo.keyId, 163 sig: signature, 164 alg: 'rsa-v1_5-sha256', 165 }) 166 ), 167 }; 168 certificateChainBody = codeSigningInfo.certificateChainForResponse.join('\n'); 169 } 170 171 const headers = this.getDefaultResponseHeaders(); 172 173 switch (requestOptions.responseContentType) { 174 case ResponseContentType.MULTIPART_MIXED: { 175 const form = this.getFormData({ 176 stringifiedManifest, 177 manifestPartHeaders, 178 certificateChainBody, 179 }); 180 headers.set('content-type', `multipart/mixed; boundary=${form.getBoundary()}`); 181 return { 182 body: form.getBuffer().toString(), 183 version: runtimeVersion, 184 headers, 185 }; 186 } 187 case ResponseContentType.APPLICATION_EXPO_JSON: 188 case ResponseContentType.APPLICATION_JSON: 189 case ResponseContentType.TEXT_PLAIN: { 190 headers.set( 191 'content-type', 192 ExpoGoManifestHandlerMiddleware.getContentTypeForResponseContentType( 193 requestOptions.responseContentType 194 ) 195 ); 196 if (manifestPartHeaders) { 197 Object.entries(manifestPartHeaders).forEach(([key, value]) => { 198 headers.set(key, value); 199 }); 200 } 201 202 return { 203 body: stringifiedManifest, 204 version: runtimeVersion, 205 headers, 206 }; 207 } 208 } 209 } 210 211 private static getContentTypeForResponseContentType( 212 responseContentType: ResponseContentType 213 ): string { 214 switch (responseContentType) { 215 case ResponseContentType.MULTIPART_MIXED: 216 return 'multipart/mixed'; 217 case ResponseContentType.APPLICATION_EXPO_JSON: 218 return 'application/expo+json'; 219 case ResponseContentType.APPLICATION_JSON: 220 return 'application/json'; 221 case ResponseContentType.TEXT_PLAIN: 222 return 'text/plain'; 223 } 224 } 225 226 private getFormData({ 227 stringifiedManifest, 228 manifestPartHeaders, 229 certificateChainBody, 230 }: { 231 stringifiedManifest: string; 232 manifestPartHeaders: { 'expo-signature': string } | null; 233 certificateChainBody: string | null; 234 }): FormData { 235 const form = new FormData(); 236 form.append('manifest', stringifiedManifest, { 237 contentType: 'application/json', 238 header: { 239 ...manifestPartHeaders, 240 }, 241 }); 242 if (certificateChainBody && certificateChainBody.length > 0) { 243 form.append('certificate_chain', certificateChainBody, { 244 contentType: 'application/x-pem-file', 245 }); 246 } 247 return form; 248 } 249 250 protected trackManifest(version?: string) { 251 logEventAsync('Serve Expo Updates Manifest', { 252 runtimeVersion: version, 253 }); 254 } 255 256 private static async getScopeKeyAsync({ 257 slug, 258 codeSigningInfo, 259 }: { 260 slug: string; 261 codeSigningInfo: CodeSigningInfo | null; 262 }): Promise<string> { 263 const scopeKeyFromCodeSigningInfo = codeSigningInfo?.scopeKey; 264 if (scopeKeyFromCodeSigningInfo) { 265 return scopeKeyFromCodeSigningInfo; 266 } 267 268 // Log.warn( 269 // env.EXPO_OFFLINE 270 // ? 'Using anonymous scope key in manifest for offline mode.' 271 // : 'Using anonymous scope key in manifest.' 272 // ); 273 return await getAnonymousScopeKeyAsync(slug); 274 } 275} 276 277async function getAnonymousScopeKeyAsync(slug: string): Promise<string> { 278 const userAnonymousIdentifier = await UserSettings.getAnonymousIdentifierAsync(); 279 return `@${ANONYMOUS_USERNAME}/${slug}-${userAnonymousIdentifier}`; 280} 281 282function convertToDictionaryItemsRepresentation(obj: { [key: string]: string }): Dictionary { 283 return new Map( 284 Object.entries(obj).map(([k, v]) => { 285 return [k, [v, new Map()]]; 286 }) 287 ); 288} 289