refactor(expo-updates): 重构manifest构建逻辑,支持动态获取metadata和真实hash计算
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
// 计算基础 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<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 hash(Base64URL 编码)
|
||||
*/
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user