refactor(expo-updates): 重构manifest构建逻辑,支持动态获取metadata和真实hash计算

This commit is contained in:
richarjiang
2025-12-05 22:34:33 +08:00
parent 190bc5bce9
commit 51d0dabc9a
5 changed files with 230 additions and 69 deletions

9
package-lock.json generated
View File

@@ -31,6 +31,7 @@
"cos-nodejs-sdk-v5": "^2.14.7", "cos-nodejs-sdk-v5": "^2.14.7",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"form-data": "^4.0.5",
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@@ -7376,14 +7377,14 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.2", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", "resolved": "https://mirrors.tencent.com/npm/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12" "mime-types": "^2.1.12"
}, },
"engines": { "engines": {

View File

@@ -49,6 +49,7 @@
"cos-nodejs-sdk-v5": "^2.14.7", "cos-nodejs-sdk-v5": "^2.14.7",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"form-data": "^4.0.5",
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",

View File

@@ -8,7 +8,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiHeader, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiHeader, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { Response } from 'express'; import { Response } from 'express';
import * as FormData from 'form-data';
import { ExpoUpdatesService } from './expo-updates.service'; import { ExpoUpdatesService } from './expo-updates.service';
import { logger } from 'src/common/logger/winston.config';
@ApiTags('Expo Updates') @ApiTags('Expo Updates')
@Controller('expo-updates') @Controller('expo-updates')
@@ -48,52 +50,54 @@ export class ExpoUpdatesController {
throw new BadRequestException('No runtimeVersion provided.'); 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) { if (currentUpdateId === manifest.id && protocolVersion === 1) {
return this.sendNoUpdateAvailable(res); return this.sendNoUpdateAvailable(res);
} }
// 构建 multipart 响应 // 使用 form-data 构建正确的 multipart 响应
const boundary = `----ExpoUpdates${Date.now()}`; const form = new FormData();
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.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-protocol-version', protocolVersion);
res.setHeader('expo-sfv-version', 0); res.setHeader('expo-sfv-version', 0);
res.setHeader('cache-control', 'private, max-age=0'); res.setHeader('cache-control', 'private, max-age=0');
res.setHeader('content-type', `multipart/mixed; boundary=${boundary}`); res.setHeader('content-type', `multipart/mixed; boundary=${form.getBoundary()}`);
res.send(parts.join('\r\n')); res.send(form.getBuffer());
} }
private sendNoUpdateAvailable(res: Response) { private sendNoUpdateAvailable(res: Response) {
const boundary = `----ExpoUpdates${Date.now()}`; const form = new FormData();
const directive = this.expoUpdatesService.createNoUpdateAvailableDirective(); 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-protocol-version', 1);
res.setHeader('expo-sfv-version', 0); res.setHeader('expo-sfv-version', 0);
res.setHeader('cache-control', 'private, max-age=0'); res.setHeader('cache-control', 'private, max-age=0');
res.setHeader('content-type', `multipart/mixed; boundary=${boundary}`); res.setHeader('content-type', `multipart/mixed; boundary=${form.getBoundary()}`);
res.send(parts.join('\r\n')); res.send(form.getBuffer());
} }
} }

View File

@@ -1,5 +1,8 @@
import { Injectable, BadRequestException } from '@nestjs/common'; import { Injectable, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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 { export interface AssetMetadata {
hash: string; hash: string;
@@ -25,61 +28,98 @@ export interface NoUpdateAvailableDirective {
type: 'noUpdateAvailable'; 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() @Injectable()
export class ExpoUpdatesService { export class ExpoUpdatesService {
private metadataCache: Map<string, MetadataCache> = new Map();
private hashCache: Map<string, HashCache> = new Map();
private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存
constructor(private configService: ConfigService) {} constructor(private configService: ConfigService) {}
/** /**
* 从环境变量构建 manifest * 从环境变量构建 manifest
* *
* 环境变量配置: * 环境变量配置:
* - EXPO_UPDATE_ID: 更新ID * - EXPO_UPDATE_ID: 更新ID(可选)
* - EXPO_RUNTIME_VERSION: 运行时版本 * - EXPO_RUNTIME_VERSION: 运行时版本
* - EXPO_CREATED_AT: 创建时间 * - EXPO_IOS_METADATA_URL: iOS metadata.json URL
* - EXPO_IOS_BUNDLE_URL: iOS bundle URL * - EXPO_ANDROID_METADATA_URL: Android metadata.json 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格式的资源数组可选
*/ */
buildManifest(platform: 'ios' | 'android', runtimeVersion: string): UpdateManifest { async buildManifest(platform: 'ios' | 'android', runtimeVersion: string): Promise<UpdateManifest> {
const configRuntimeVersion = this.configService.get<string>('EXPO_RUNTIME_VERSION'); const configRuntimeVersion = this.configService.get<string>('EXPO_RUNTIME_VERSION');
logger.info(`buildManifest: configRuntimeVersion=${configRuntimeVersion}, runtimeVersion=${runtimeVersion}`);
// 检查运行时版本是否匹配 // 检查运行时版本是否匹配
if (configRuntimeVersion && configRuntimeVersion !== runtimeVersion) { if (configRuntimeVersion && configRuntimeVersion !== runtimeVersion) {
throw new BadRequestException(`No update available for runtime version: ${runtimeVersion}`); throw new BadRequestException(`No update available for runtime version: ${runtimeVersion}`);
} }
const bundleUrl = platform === 'ios' const metadataUrl = platform === 'ios'
? this.configService.get<string>('EXPO_IOS_BUNDLE_URL') ? this.configService.get<string>('EXPO_IOS_METADATA_URL')
: this.configService.get<string>('EXPO_ANDROID_BUNDLE_URL'); : this.configService.get<string>('EXPO_ANDROID_METADATA_URL');
const bundleHash = platform === 'ios' if (!metadataUrl) {
? this.configService.get<string>('EXPO_IOS_BUNDLE_HASH') throw new BadRequestException(`No metadata URL configured for platform: ${platform}`);
: this.configService.get<string>('EXPO_ANDROID_BUNDLE_HASH');
if (!bundleUrl || !bundleHash) {
throw new BadRequestException(`No update configured for platform: ${platform}`);
} }
// 解析资源配置 // 获取 metadata.json 内容
const assetsJson = this.configService.get<string>('EXPO_ASSETS'); const metadata = await this.fetchMetadata(metadataUrl);
let assets: AssetMetadata[] = []; const platformMetadata = metadata.fileMetadata[platform];
if (assetsJson) {
try { if (!platformMetadata) {
assets = JSON.parse(assetsJson); throw new BadRequestException(`No ${platform} metadata found in metadata.json`);
} catch {
assets = [];
}
} }
// 计算基础 URLmetadata.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 固定 // ID 基于 bundle hash 生成,确保内容不变时 ID 固定
const updateId = this.configService.get<string>('EXPO_UPDATE_ID') const updateId = this.configService.get<string>('EXPO_UPDATE_ID')
|| this.hashToUUID(bundleHash); || this.convertSHA256HashToUUID(bundleHash);
return { return {
id: updateId, id: updateId,
createdAt: this.configService.get<string>('EXPO_CREATED_AT') || new Date().toISOString(), createdAt: new Date().toISOString(),
runtimeVersion: configRuntimeVersion || runtimeVersion, runtimeVersion: configRuntimeVersion || runtimeVersion,
launchAsset: { launchAsset: {
hash: bundleHash, hash: bundleHash,
@@ -93,16 +133,130 @@ export class ExpoUpdatesService {
}; };
} }
/**
* 获取 metadata.json 内容(带缓存)
*/
private async fetchMetadata(url: string): Promise<MetadataFile> {
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<MetadataFile>(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 hashBase64URL 编码)
*/
private async calculateFileHash(url: string): Promise<string> {
// 检查缓存
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<AssetMetadata[]> {
// 去重:相同 path 的 asset 只保留一个
const uniqueAssets = new Map<string, MetadataFileAsset>();
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<string, string> = {
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 { createNoUpdateAvailableDirective(): NoUpdateAvailableDirective {
return { type: 'noUpdateAvailable' }; return { type: 'noUpdateAvailable' };
} }
/** /**
* 将 hash 转换为 UUID 格式 * 将 SHA-256 hash 转换为 UUID 格式
*/ */
private hashToUUID(hash: string): string { private convertSHA256HashToUUID(hash: string): string {
// 使用 hash 的前32个字符生成 UUID 格式 // 将 base64url 转为 hex然后格式化为 UUID
const hex = Buffer.from(hash, 'base64url').toString('hex').padEnd(32, '0').slice(0, 32); 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)}`; return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
} }
} }

View File

@@ -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" resolved "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz"
integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw== integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==
form-data@^4.0.0: form-data@^4.0.0, form-data@^4.0.5:
version "4.0.2" version "4.0.5"
resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz" resolved "https://mirrors.tencent.com/npm/form-data/-/form-data-4.0.5.tgz"
integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==
dependencies: dependencies:
asynckit "^0.4.0" asynckit "^0.4.0"
combined-stream "^1.0.8" combined-stream "^1.0.8"
es-set-tostringtag "^2.1.0" es-set-tostringtag "^2.1.0"
hasown "^2.0.2"
mime-types "^2.1.12" mime-types "^2.1.12"
form-data@~2.3.2: form-data@~2.3.2: