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

View File

@@ -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());
}
}

View File

@@ -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<string, MetadataCache> = new Map();
private hashCache: Map<string, HashCache> = 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<UpdateManifest> {
const configRuntimeVersion = this.configService.get<string>('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<string>('EXPO_IOS_BUNDLE_URL')
: this.configService.get<string>('EXPO_ANDROID_BUNDLE_URL');
const metadataUrl = platform === 'ios'
? this.configService.get<string>('EXPO_IOS_METADATA_URL')
: this.configService.get<string>('EXPO_ANDROID_METADATA_URL');
const bundleHash = platform === 'ios'
? this.configService.get<string>('EXPO_IOS_BUNDLE_HASH')
: this.configService.get<string>('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<string>('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`);
}
// 计算基础 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 固定
const updateId = this.configService.get<string>('EXPO_UPDATE_ID')
|| this.hashToUUID(bundleHash);
|| this.convertSHA256HashToUUID(bundleHash);
return {
id: updateId,
createdAt: this.configService.get<string>('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<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 {
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)}`;
}
}