From 51d0dabc9abe8e3bba0439fbcf1461d53642356e Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 5 Dec 2025 22:34:33 +0800 Subject: [PATCH] =?UTF-8?q?refactor(expo-updates):=20=E9=87=8D=E6=9E=84man?= =?UTF-8?q?ifest=E6=9E=84=E5=BB=BA=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8A=A8=E6=80=81=E8=8E=B7=E5=8F=96metadata=E5=92=8C?= =?UTF-8?q?=E7=9C=9F=E5=AE=9Ehash=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 9 +- package.json | 1 + src/expo-updates/expo-updates.controller.ts | 62 +++--- src/expo-updates/expo-updates.service.ts | 218 +++++++++++++++++--- yarn.lock | 9 +- 5 files changed, 230 insertions(+), 69 deletions(-) diff --git a/package-lock.json b/package-lock.json index fc8dd12..48aee0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "cos-nodejs-sdk-v5": "^2.14.7", "crypto-js": "^4.2.0", "dayjs": "^1.11.18", + "form-data": "^4.0.5", "fs": "^0.0.1-security", "ioredis": "^5.8.2", "jsonwebtoken": "^9.0.2", @@ -7376,14 +7377,14 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", - "license": "MIT", + "version": "4.0.5", + "resolved": "https://mirrors.tencent.com/npm/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { diff --git a/package.json b/package.json index 3fac396..a418bef 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "cos-nodejs-sdk-v5": "^2.14.7", "crypto-js": "^4.2.0", "dayjs": "^1.11.18", + "form-data": "^4.0.5", "fs": "^0.0.1-security", "ioredis": "^5.8.2", "jsonwebtoken": "^9.0.2", diff --git a/src/expo-updates/expo-updates.controller.ts b/src/expo-updates/expo-updates.controller.ts index bca790b..04ef515 100644 --- a/src/expo-updates/expo-updates.controller.ts +++ b/src/expo-updates/expo-updates.controller.ts @@ -8,7 +8,9 @@ import { } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiHeader, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { Response } from 'express'; +import * as FormData from 'form-data'; import { ExpoUpdatesService } from './expo-updates.service'; +import { logger } from 'src/common/logger/winston.config'; @ApiTags('Expo Updates') @Controller('expo-updates') @@ -48,52 +50,54 @@ export class ExpoUpdatesController { throw new BadRequestException('No runtimeVersion provided.'); } - const manifest = this.expoUpdatesService.buildManifest(platform as 'ios' | 'android', runtimeVersion); + logger.info(`Getting manifest for platform: ${platform}, runtimeVersion: ${runtimeVersion}`); + const manifest = await this.expoUpdatesService.buildManifest(platform as 'ios' | 'android', runtimeVersion); + + logger.info(`Manifest: ${JSON.stringify(manifest)}`); // 已是最新版本 if (currentUpdateId === manifest.id && protocolVersion === 1) { return this.sendNoUpdateAvailable(res); } - // 构建 multipart 响应 - const boundary = `----ExpoUpdates${Date.now()}`; - const parts = [ - `--${boundary}`, - 'content-disposition: form-data; name="manifest"', - 'content-type: application/json; charset=utf-8', - '', - JSON.stringify(manifest), - `--${boundary}`, - 'content-disposition: form-data; name="extensions"', - 'content-type: application/json', - '', - JSON.stringify({ assetRequestHeaders: {} }), - `--${boundary}--`, - ]; + // 使用 form-data 构建正确的 multipart 响应 + const form = new FormData(); + + form.append('manifest', JSON.stringify(manifest), { + contentType: 'application/json', + header: { + 'content-type': 'application/json; charset=utf-8', + }, + }); + form.append('extensions', JSON.stringify({ assetRequestHeaders: {} }), { + contentType: 'application/json', + }); + + res.statusCode = 200; res.setHeader('expo-protocol-version', protocolVersion); res.setHeader('expo-sfv-version', 0); res.setHeader('cache-control', 'private, max-age=0'); - res.setHeader('content-type', `multipart/mixed; boundary=${boundary}`); - res.send(parts.join('\r\n')); + res.setHeader('content-type', `multipart/mixed; boundary=${form.getBoundary()}`); + res.send(form.getBuffer()); } private sendNoUpdateAvailable(res: Response) { - const boundary = `----ExpoUpdates${Date.now()}`; + const form = new FormData(); const directive = this.expoUpdatesService.createNoUpdateAvailableDirective(); - const parts = [ - `--${boundary}`, - 'content-disposition: form-data; name="directive"', - 'content-type: application/json; charset=utf-8', - '', - JSON.stringify(directive), - `--${boundary}--`, - ]; + form.append('directive', JSON.stringify(directive), { + contentType: 'application/json', + header: { + 'content-type': 'application/json; charset=utf-8', + }, + }); + + res.statusCode = 200; res.setHeader('expo-protocol-version', 1); res.setHeader('expo-sfv-version', 0); res.setHeader('cache-control', 'private, max-age=0'); - res.setHeader('content-type', `multipart/mixed; boundary=${boundary}`); - res.send(parts.join('\r\n')); + res.setHeader('content-type', `multipart/mixed; boundary=${form.getBoundary()}`); + res.send(form.getBuffer()); } } diff --git a/src/expo-updates/expo-updates.service.ts b/src/expo-updates/expo-updates.service.ts index 6e72ffc..cf0b021 100644 --- a/src/expo-updates/expo-updates.service.ts +++ b/src/expo-updates/expo-updates.service.ts @@ -1,5 +1,8 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { logger } from 'src/common/logger/winston.config'; +import axios from 'axios'; +import * as crypto from 'crypto'; export interface AssetMetadata { hash: string; @@ -25,61 +28,98 @@ export interface NoUpdateAvailableDirective { type: 'noUpdateAvailable'; } +interface MetadataFileAsset { + path: string; + ext: string; +} + +interface MetadataFile { + version: number; + bundler: string; + fileMetadata: { + ios?: { + bundle: string; + assets: MetadataFileAsset[]; + }; + android?: { + bundle: string; + assets: MetadataFileAsset[]; + }; + }; +} + +// 缓存 metadata 数据 +interface MetadataCache { + data: MetadataFile; + timestamp: number; +} + +// 缓存 hash 数据 +interface HashCache { + hash: string; + timestamp: number; +} + @Injectable() export class ExpoUpdatesService { + private metadataCache: Map = new Map(); + private hashCache: Map = new Map(); + private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存 + constructor(private configService: ConfigService) {} /** * 从环境变量构建 manifest * * 环境变量配置: - * - EXPO_UPDATE_ID: 更新ID + * - EXPO_UPDATE_ID: 更新ID(可选) * - EXPO_RUNTIME_VERSION: 运行时版本 - * - EXPO_CREATED_AT: 创建时间 - * - EXPO_IOS_BUNDLE_URL: iOS bundle URL - * - EXPO_IOS_BUNDLE_HASH: iOS bundle hash - * - EXPO_ANDROID_BUNDLE_URL: Android bundle URL - * - EXPO_ANDROID_BUNDLE_HASH: Android bundle hash - * - EXPO_ASSETS: JSON格式的资源数组(可选) + * - EXPO_IOS_METADATA_URL: iOS metadata.json URL + * - EXPO_ANDROID_METADATA_URL: Android metadata.json URL */ - buildManifest(platform: 'ios' | 'android', runtimeVersion: string): UpdateManifest { + async buildManifest(platform: 'ios' | 'android', runtimeVersion: string): Promise { const configRuntimeVersion = this.configService.get('EXPO_RUNTIME_VERSION'); + logger.info(`buildManifest: configRuntimeVersion=${configRuntimeVersion}, runtimeVersion=${runtimeVersion}`); + // 检查运行时版本是否匹配 if (configRuntimeVersion && configRuntimeVersion !== runtimeVersion) { throw new BadRequestException(`No update available for runtime version: ${runtimeVersion}`); } - const bundleUrl = platform === 'ios' - ? this.configService.get('EXPO_IOS_BUNDLE_URL') - : this.configService.get('EXPO_ANDROID_BUNDLE_URL'); + const metadataUrl = platform === 'ios' + ? this.configService.get('EXPO_IOS_METADATA_URL') + : this.configService.get('EXPO_ANDROID_METADATA_URL'); - const bundleHash = platform === 'ios' - ? this.configService.get('EXPO_IOS_BUNDLE_HASH') - : this.configService.get('EXPO_ANDROID_BUNDLE_HASH'); - - if (!bundleUrl || !bundleHash) { - throw new BadRequestException(`No update configured for platform: ${platform}`); + if (!metadataUrl) { + throw new BadRequestException(`No metadata URL configured for platform: ${platform}`); } - // 解析资源配置 - const assetsJson = this.configService.get('EXPO_ASSETS'); - let assets: AssetMetadata[] = []; - if (assetsJson) { - try { - assets = JSON.parse(assetsJson); - } catch { - assets = []; - } + // 获取 metadata.json 内容 + const metadata = await this.fetchMetadata(metadataUrl); + const platformMetadata = metadata.fileMetadata[platform]; + + if (!platformMetadata) { + throw new BadRequestException(`No ${platform} metadata found in metadata.json`); } + // 计算基础 URL(metadata.json 所在目录) + const baseUrl = metadataUrl.substring(0, metadataUrl.lastIndexOf('/') + 1); + + // 构建 bundle URL 并计算真实 hash + const bundleUrl = baseUrl + platformMetadata.bundle; + const bundleHash = await this.calculateFileHash(bundleUrl); + + // 构建 assets(需要计算每个文件的真实 hash) + const assets = await this.buildAssetsWithHash(platformMetadata.assets, baseUrl); + // ID 基于 bundle hash 生成,确保内容不变时 ID 固定 const updateId = this.configService.get('EXPO_UPDATE_ID') - || this.hashToUUID(bundleHash); + || this.convertSHA256HashToUUID(bundleHash); return { id: updateId, - createdAt: this.configService.get('EXPO_CREATED_AT') || new Date().toISOString(), + createdAt: new Date().toISOString(), runtimeVersion: configRuntimeVersion || runtimeVersion, launchAsset: { hash: bundleHash, @@ -93,16 +133,130 @@ export class ExpoUpdatesService { }; } + /** + * 获取 metadata.json 内容(带缓存) + */ + private async fetchMetadata(url: string): Promise { + const cached = this.metadataCache.get(url); + if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { + logger.info(`Using cached metadata for ${url}`); + return cached.data; + } + + logger.info(`Fetching metadata from ${url}`); + try { + const response = await axios.get(url, { timeout: 10000 }); + const data = response.data; + + // 缓存数据 + this.metadataCache.set(url, { + data, + timestamp: Date.now(), + }); + + return data; + } catch (error) { + logger.error(`Failed to fetch metadata: ${error.message}`); + throw new BadRequestException(`Failed to fetch metadata from ${url}`); + } + } + + /** + * 计算文件的 SHA-256 hash(Base64URL 编码) + */ + private async calculateFileHash(url: string): Promise { + // 检查缓存 + const cacheKey = `hash:${url}`; + const cached = this.hashCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { + return cached.hash; + } + + try { + const response = await axios.get(url, { + responseType: 'arraybuffer', + timeout: 30000, + }); + const hash = crypto.createHash('sha256').update(response.data).digest('base64url'); + + // 缓存 hash + this.hashCache.set(cacheKey, { hash, timestamp: Date.now() }); + + return hash; + } catch (error) { + logger.error(`Failed to calculate hash for ${url}: ${error.message}`); + throw new BadRequestException(`Failed to fetch asset: ${url}`); + } + } + + /** + * 构建 assets 列表(带真实 hash 计算) + */ + private async buildAssetsWithHash(assets: MetadataFileAsset[], baseUrl: string): Promise { + // 去重:相同 path 的 asset 只保留一个 + const uniqueAssets = new Map(); + for (const asset of assets) { + if (!uniqueAssets.has(asset.path)) { + uniqueAssets.set(asset.path, asset); + } + } + + const assetList = Array.from(uniqueAssets.values()); + + // 并行计算所有 asset 的 hash + const results = await Promise.all( + assetList.map(async (asset) => { + const url = baseUrl + asset.path; + const key = asset.path.split('/').pop() || ''; // 使用文件名作为 key + const hash = await this.calculateFileHash(url); + + return { + hash, + key, + contentType: this.getContentType(asset.ext), + fileExtension: `.${asset.ext}`, + url, + }; + }) + ); + + return results; + } + + /** + * 根据扩展名获取 content type + */ + private getContentType(ext: string): string { + const contentTypes: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + ttf: 'font/ttf', + otf: 'font/otf', + woff: 'font/woff', + woff2: 'font/woff2', + js: 'application/javascript', + json: 'application/json', + mp3: 'audio/mpeg', + mp4: 'video/mp4', + wav: 'audio/wav', + }; + return contentTypes[ext.toLowerCase()] || 'application/octet-stream'; + } + createNoUpdateAvailableDirective(): NoUpdateAvailableDirective { return { type: 'noUpdateAvailable' }; } /** - * 将 hash 转换为 UUID 格式 + * 将 SHA-256 hash 转换为 UUID 格式 */ - private hashToUUID(hash: string): string { - // 使用 hash 的前32个字符生成 UUID 格式 - const hex = Buffer.from(hash, 'base64url').toString('hex').padEnd(32, '0').slice(0, 32); + private convertSHA256HashToUUID(hash: string): string { + // 将 base64url 转为 hex,然后格式化为 UUID + const hex = Buffer.from(hash, 'base64url').toString('hex').slice(0, 32); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; } } diff --git a/yarn.lock b/yarn.lock index 40a61f2..1933c9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3873,14 +3873,15 @@ form-data-encoder@^2.1.2: resolved "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz" integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw== -form-data@^4.0.0: - version "4.0.2" - resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz" - integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== +form-data@^4.0.0, form-data@^4.0.5: + version "4.0.5" + resolved "https://mirrors.tencent.com/npm/form-data/-/form-data-4.0.5.tgz" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" es-set-tostringtag "^2.1.0" + hasown "^2.0.2" mime-types "^2.1.12" form-data@~2.3.2: