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 } = await this._resolveProjectSettingsAsync( 103 requestOptions 104 ); 105 106 const runtimeVersion = await Updates.getRuntimeVersionAsync( 107 this.projectRoot, 108 { ...exp, runtimeVersion: exp.runtimeVersion ?? { policy: 'sdkVersion' } }, 109 requestOptions.platform 110 ); 111 if (!runtimeVersion) { 112 throw new CommandError( 113 'MANIFEST_MIDDLEWARE', 114 `Unable to determine runtime version for platform '${requestOptions.platform}'` 115 ); 116 } 117 118 const codeSigningInfo = await getCodeSigningInfoAsync( 119 exp, 120 requestOptions.expectSignature, 121 this.options.privateKeyPath 122 ); 123 124 const easProjectId = exp.extra?.eas?.projectId as string | undefined | null; 125 const scopeKey = await ExpoGoManifestHandlerMiddleware.getScopeKeyAsync({ 126 slug: exp.slug, 127 codeSigningInfo, 128 }); 129 130 const expoUpdatesManifest: ExpoUpdatesManifest = { 131 id: crypto.randomUUID(), 132 createdAt: new Date().toISOString(), 133 runtimeVersion, 134 launchAsset: { 135 key: 'bundle', 136 contentType: 'application/javascript', 137 url: bundleUrl, 138 }, 139 assets: [], // assets are not used in development 140 metadata: {}, // required for the client to detect that this is an expo-updates manifest 141 extra: { 142 eas: { 143 projectId: easProjectId ?? undefined, 144 }, 145 expoClient: { 146 ...exp, 147 hostUri, 148 }, 149 expoGo: expoGoConfig, 150 scopeKey, 151 }, 152 }; 153 154 const stringifiedManifest = JSON.stringify(expoUpdatesManifest); 155 156 let manifestPartHeaders: { 'expo-signature': string } | null = null; 157 let certificateChainBody: string | null = null; 158 if (codeSigningInfo) { 159 const signature = signManifestString(stringifiedManifest, codeSigningInfo); 160 manifestPartHeaders = { 161 'expo-signature': serializeDictionary( 162 convertToDictionaryItemsRepresentation({ 163 keyid: codeSigningInfo.keyId, 164 sig: signature, 165 alg: 'rsa-v1_5-sha256', 166 }) 167 ), 168 }; 169 certificateChainBody = codeSigningInfo.certificateChainForResponse.join('\n'); 170 } 171 172 const headers = this.getDefaultResponseHeaders(); 173 174 switch (requestOptions.responseContentType) { 175 case ResponseContentType.MULTIPART_MIXED: { 176 const form = this.getFormData({ 177 stringifiedManifest, 178 manifestPartHeaders, 179 certificateChainBody, 180 }); 181 headers.set('content-type', `multipart/mixed; boundary=${form.getBoundary()}`); 182 return { 183 body: form.getBuffer().toString(), 184 version: runtimeVersion, 185 headers, 186 }; 187 } 188 case ResponseContentType.APPLICATION_EXPO_JSON: 189 case ResponseContentType.APPLICATION_JSON: 190 case ResponseContentType.TEXT_PLAIN: { 191 headers.set( 192 'content-type', 193 ExpoGoManifestHandlerMiddleware.getContentTypeForResponseContentType( 194 requestOptions.responseContentType 195 ) 196 ); 197 if (manifestPartHeaders) { 198 Object.entries(manifestPartHeaders).forEach(([key, value]) => { 199 headers.set(key, value); 200 }); 201 } 202 203 return { 204 body: stringifiedManifest, 205 version: runtimeVersion, 206 headers, 207 }; 208 } 209 } 210 } 211 212 private static getContentTypeForResponseContentType( 213 responseContentType: ResponseContentType 214 ): string { 215 switch (responseContentType) { 216 case ResponseContentType.MULTIPART_MIXED: 217 return 'multipart/mixed'; 218 case ResponseContentType.APPLICATION_EXPO_JSON: 219 return 'application/expo+json'; 220 case ResponseContentType.APPLICATION_JSON: 221 return 'application/json'; 222 case ResponseContentType.TEXT_PLAIN: 223 return 'text/plain'; 224 } 225 } 226 227 private getFormData({ 228 stringifiedManifest, 229 manifestPartHeaders, 230 certificateChainBody, 231 }: { 232 stringifiedManifest: string; 233 manifestPartHeaders: { 'expo-signature': string } | null; 234 certificateChainBody: string | null; 235 }): FormData { 236 const form = new FormData(); 237 form.append('manifest', stringifiedManifest, { 238 contentType: 'application/json', 239 header: { 240 ...manifestPartHeaders, 241 }, 242 }); 243 if (certificateChainBody && certificateChainBody.length > 0) { 244 form.append('certificate_chain', certificateChainBody, { 245 contentType: 'application/x-pem-file', 246 }); 247 } 248 return form; 249 } 250 251 protected trackManifest(version?: string) { 252 logEventAsync('Serve Expo Updates Manifest', { 253 runtimeVersion: version, 254 }); 255 } 256 257 private static async getScopeKeyAsync({ 258 slug, 259 codeSigningInfo, 260 }: { 261 slug: string; 262 codeSigningInfo: CodeSigningInfo | null; 263 }): Promise<string> { 264 const scopeKeyFromCodeSigningInfo = codeSigningInfo?.scopeKey; 265 if (scopeKeyFromCodeSigningInfo) { 266 return scopeKeyFromCodeSigningInfo; 267 } 268 269 // Log.warn( 270 // env.EXPO_OFFLINE 271 // ? 'Using anonymous scope key in manifest for offline mode.' 272 // : 'Using anonymous scope key in manifest.' 273 // ); 274 return await getAnonymousScopeKeyAsync(slug); 275 } 276} 277 278async function getAnonymousScopeKeyAsync(slug: string): Promise<string> { 279 const userAnonymousIdentifier = await UserSettings.getAnonymousIdentifierAsync(); 280 return `@${ANONYMOUS_USERNAME}/${slug}-${userAnonymousIdentifier}`; 281} 282 283function convertToDictionaryItemsRepresentation(obj: { [key: string]: string }): Dictionary { 284 return new Map( 285 Object.entries(obj).map(([k, v]) => { 286 return [k, [v, new Map()]]; 287 }) 288 ); 289} 290