diff --git a/.changeset/bds-official-version-source.md b/.changeset/bds-official-version-source.md new file mode 100644 index 00000000..666e6ebd --- /dev/null +++ b/.changeset/bds-official-version-source.md @@ -0,0 +1,6 @@ +--- +"@sfmc-bds/bds-tools": patch +"@sfmc-bds/sdk": patch +--- + +BDS 更新检查改为优先使用 Mojang 官方下载接口获取最新版本,失败再回退到 Bedrock-OSS 版本列表(GitHub raw 与 jsDelivr 镜像)。修复 GitHub 不可达时只能依赖 jsDelivr 旧缓存,新版本发布初期检查结果时新时旧的问题。Bedrock-OSS 尚未收录新版本或不可达时,改用官方下载地址(跳过哈希校验)。自定义了 `version_versions` 或使用 endstone 模式时行为不变。同时修复 HTTP 请求与下载成功(及重定向、4xx)后未清除超时计时器,导致一次性命令在完成后仍空等至超时才退出的问题。 diff --git a/modules/sdk/@sfmc-sdk/schemas/bds_updater.schema.json b/modules/sdk/@sfmc-sdk/schemas/bds_updater.schema.json index 9bb40d82..34e73ffd 100644 --- a/modules/sdk/@sfmc-sdk/schemas/bds_updater.schema.json +++ b/modules/sdk/@sfmc-sdk/schemas/bds_updater.schema.json @@ -46,7 +46,7 @@ }, "version_versions": { "type": "string", - "description": "版本列表 API URL" + "description": "版本列表 API URL(Bedrock-OSS 格式)。不填时先查 Mojang 官方下载接口,失败再用 Bedrock-OSS 列表;填写后只用此地址" }, "version_versions_mirror": { "type": "string", diff --git a/packages/bds-tools/src/check-update.ts b/packages/bds-tools/src/check-update.ts index 9c79f3ec..d87d586d 100644 --- a/packages/bds-tools/src/check-update.ts +++ b/packages/bds-tools/src/check-update.ts @@ -39,6 +39,7 @@ import { isVersionCompatible, verifyFileHash, } from "./upstream.js"; +import type { VersionDetails } from "./types.js"; import { compareVersions, getCurrentVersionAsync, getCurrentVersionSync, saveVersionCache } from "./version.js"; import { extractZipFileToDir } from "./zipx.js"; @@ -185,9 +186,12 @@ export async function runUpdate(): Promise { // 2. 获取最新版本 let latestVer: string; + /** 版本号来自 Mojang 官方下载接口时的官方下载地址;Bedrock-OSS 版本详情不可用时作为下载来源 */ + let officialUrl = ""; try { const info = await getVersionInfo(cfg, channel); latestVer = info.version; + officialUrl = info.downloadUrl ?? ""; log.info(`最新版本: ${latestVer}`); const cmp = compareVersions(latestVer, currentVer); if (cmp <= 0 && !force) { @@ -220,7 +224,15 @@ export async function runUpdate(): Promise { let downloadUrls: string[]; let hash: { sha1: string; sha256: string }; try { - const details = await fetchVersionDetails(cfg, channel, latestVer); + let details: VersionDetails; + try { + details = await fetchVersionDetails(cfg, channel, latestVer); + } catch (e) { + // 官方刚发布时 Bedrock-OSS 可能尚未收录该版本,或其源在当前网络不可达:改用官方下载地址,无哈希可校验 + if (!officialUrl) throw e; + log.warn(`版本详情不可用: ${(e as Error).message},改用官方下载地址(跳过哈希校验)`); + details = { downloadUrl: officialUrl, sha1: "", sha256: "", size: 0 }; + } downloadUrls = buildDownloadUrls(cfg, channel, latestVer, details); hash = { sha1: details.sha1, sha256: details.sha256 }; } catch (e) { diff --git a/packages/bds-tools/src/http.ts b/packages/bds-tools/src/http.ts index fd0bbbd9..7d85149a 100644 --- a/packages/bds-tools/src/http.ts +++ b/packages/bds-tools/src/http.ts @@ -61,6 +61,7 @@ export async function httpRequest( const chunks: Buffer[] = []; res.on("data", (c: Buffer) => chunks.push(c)); res.on("end", () => { + clearTimers(); const body = Buffer.concat(chunks); const status = res.statusCode ?? 0; // 3xx → 跟随 Location @@ -87,11 +88,15 @@ export async function httpRequest( const connectTimer = setTimeout(() => { req.destroy(new Error(`HTTP 连接超时 ${connectTimeoutMs}ms: ${url}`)); }, connectTimeoutMs); + /** 清除本次请求的两个超时计时器;响应读完(含成功、重定向、4xx)与请求出错时调用,避免计时器让进程空等到超时才退出。 */ + const clearTimers = (): void => { + clearTimeout(totalTimer); + clearTimeout(connectTimer); + }; req.on("socket", () => clearTimeout(connectTimer)); req.on("error", (err) => { - clearTimeout(totalTimer); - clearTimeout(connectTimer); + clearTimers(); reject(err); }); @@ -204,6 +209,8 @@ export async function httpDownload( (res) => { const status = res.statusCode ?? 0; if (status >= 300 && status < 400 && redirects < MAX_REDIRECTS) { + // 重定向后由新请求自带计时器,本请求的计时器需清除,否则旧计时器到期会销毁已结束的请求并误报错误 + clearTimers(); const loc = res.headers.location; if (!loc) return reject(new Error(`HTTP ${status} 缺少 Location`)); redirects++; @@ -214,7 +221,10 @@ export async function httpDownload( .catch(reject); return; } - if (status >= 400) return reject(new Error(`HTTP ${status} for ${url}`)); + if (status >= 400) { + clearTimers(); + return reject(new Error(`HTTP ${status} for ${url}`)); + } const total = parseInt(String(res.headers["content-length"] ?? 0), 10) || 0; const file = createWriteStream(destPath); @@ -228,6 +238,7 @@ export async function httpDownload( const handleError = (e: Error): void => { if (failed) return; failed = true; + clearTimers(); file.close(); // 清理 partial 文件 import("node:fs").then((fs) => { @@ -259,6 +270,7 @@ export async function httpDownload( // 不能直接监听 file 'finish' 因为我们在错误时手动 handleError file.on("finish", () => { if (failed) return; + clearTimers(); try { const finalBytes = statSync(destPath).size; // 收尾:确保最后一次回调让进度条走到 100%(即便最近 @@ -281,11 +293,15 @@ export async function httpDownload( const connectTimer = setTimeout(() => { req.destroy(new Error(`下载连接超时 ${connectTimeoutMs}ms: ${url}`)); }, connectTimeoutMs); + /** 清除本次下载的两个超时计时器;下载完成、重定向、4xx 与任一出错路径调用,避免计时器在下载结束后继续存活。 */ + const clearTimers = (): void => { + clearTimeout(totalTimer); + clearTimeout(connectTimer); + }; req.on("socket", () => clearTimeout(connectTimer)); req.on("error", (e) => { - clearTimeout(totalTimer); - clearTimeout(connectTimer); + clearTimers(); log.warn(`HTTP 错误 ${url}: ${(e as Error).message}`); reject(e); }); diff --git a/packages/bds-tools/src/types.ts b/packages/bds-tools/src/types.ts index a9cdc7fc..6f2401db 100644 --- a/packages/bds-tools/src/types.ts +++ b/packages/bds-tools/src/types.ts @@ -31,6 +31,8 @@ export interface BdsUpdaterConfig { export interface VersionInfo { version: string; cdnRoot: string; + /** 版本号取自 Mojang 官方下载接口时,该版本的官方下载地址;Bedrock-OSS 版本列表来源时为空 */ + downloadUrl?: string; } export interface VersionDetails { diff --git a/packages/bds-tools/src/upstream.ts b/packages/bds-tools/src/upstream.ts index 22279789..fc7ceecb 100644 --- a/packages/bds-tools/src/upstream.ts +++ b/packages/bds-tools/src/upstream.ts @@ -11,7 +11,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; import { bdsCdnZipSuffix, bdsDetailsPlatformKey, bdsHostOs, type BdsHostOs } from "./host-platform.js"; -import { fetchJsonWithFallback } from "./http.js"; +import { fetchJsonWithFallback, httpGetJson } from "./http.js"; import { log } from "./log.js"; import type { BdsUpdaterConfig, VersionDetails, VersionInfo } from "./types.js"; import { toVer3 } from "./version.js"; @@ -19,6 +19,62 @@ import { toVer3 } from "./version.js"; const DEFAULT_VERSIONS_API = "https://raw.githubusercontent.com/Bedrock-OSS/BDS-Versions/main/versions.json"; const DEFAULT_VERSIONS_MIRROR = "https://cdn.jsdelivr.net/gh/Bedrock-OSS/BDS-Versions@main/versions.json"; +/** + * Mojang 官方下载链接接口:返回各平台当前正式版 / 预览版 BDS 的下载地址(文件名带版本号)。 + * 使用场景:getVersionInfo 的首选版本源。GitHub raw 在部分网络下不可达,jsDelivr 镜像在新版本发布初期 + * 会返回旧缓存,导致检查结果时新时旧;官方接口无此问题。 + */ +const OFFICIAL_LINKS_API = "https://net-secondary.web.minecraft-services.net/api/v1.0/download/links"; +const OFFICIAL_LINKS_TIMEOUT_MS = 15_000; + +/** 官方接口解析结果:当前通道与平台的 BDS 版本号及其官方下载地址。 */ +export interface OfficialServerLink { + version: string; + downloadUrl: string; +} + +/** + * 官方接口中当前通道与平台对应的 downloadType,例如 serverBedrockWindows、serverBedrockPreviewLinux。 + * 使用场景:parseOfficialServerLink 从 links 列表中挑选条目。 + */ +function officialDownloadType(channel: string, hostOs: BdsHostOs): string { + const preview = channel === "preview" ? "Preview" : ""; + const os = hostOs === "windows" ? "Windows" : "Linux"; + return `serverBedrock${preview}${os}`; +} + +/** + * 从官方接口响应中解析当前通道与平台的 BDS 版本号与下载地址;结构不符或地址中没有版本号时返回 null。 + * 使用场景:fetchOfficialServerLink;单独导出便于离线校验解析规则。 + */ +export function parseOfficialServerLink(json: unknown, channel: string, hostOs: BdsHostOs): OfficialServerLink | null { + const links = (json as { result?: { links?: unknown } } | null)?.result?.links; + if (!Array.isArray(links)) return null; + const type = officialDownloadType(channel, hostOs); + const entry = links.find((l: { downloadType?: unknown }) => l?.downloadType === type) as + | { downloadUrl?: unknown } + | undefined; + const url = typeof entry?.downloadUrl === "string" ? entry.downloadUrl : ""; + const m = /bedrock-server-(\d+(?:\.\d+){2,3})(?:-preview)?\.zip$/i.exec(url); + return m ? { version: m[1]!, downloadUrl: url } : null; +} + +/** + * 请求官方接口并解析当前通道的 BDS 版本;请求失败或解析不到时记录警告并返回 null,由调用方回退到 Bedrock-OSS 版本列表。 + * 使用场景:getVersionInfo 在 bedrock-oss 模式且未自定义版本列表地址时优先调用。 + */ +async function fetchOfficialServerLink(channel: string, hostOs: BdsHostOs): Promise { + try { + const json = await httpGetJson(OFFICIAL_LINKS_API, { totalTimeoutMs: OFFICIAL_LINKS_TIMEOUT_MS }); + const link = parseOfficialServerLink(json, channel, hostOs); + if (!link) log.warn("官方下载接口中没有当前通道的 BDS 下载地址,改用 Bedrock-OSS 版本列表"); + return link; + } catch (e) { + log.warn(`官方下载接口不可用: ${(e as Error).message},改用 Bedrock-OSS 版本列表`); + return null; + } +} + function resolveTemplate(tpl: string, vars: Record): string { let s = tpl; for (const [k, v] of Object.entries(vars)) { @@ -29,6 +85,8 @@ function resolveTemplate(tpl: string, vars: Record): string { /** * 获取 BDS 最新可用版本信息(支持重试与指数退避)。 + * bedrock-oss 模式且未自定义版本列表地址(version_versions / BDS_VERSIONS_API)时,先查 Mojang 官方下载接口, + * 失败再用 Bedrock-OSS 版本列表(GitHub raw 与 jsDelivr 镜像)。 * * @param cfg BDS 更新器配置。 * @param channel 更新通道(如 "release" 或 "preview")。 @@ -41,11 +99,18 @@ export async function getVersionInfo( channel: string, hostOs: BdsHostOs = bdsHostOs() ): Promise { - const api = cfg.version_versions || process.env["BDS_VERSIONS_API"] || DEFAULT_VERSIONS_API; + const customApi = cfg.version_versions || process.env["BDS_VERSIONS_API"]; + const api = customApi || DEFAULT_VERSIONS_API; const mirror = cfg.version_versions_mirror || process.env["BDS_VERSIONS_MIRROR"] || DEFAULT_VERSIONS_MIRROR; const sources = [api, mirror].filter(Boolean); const versionMode = cfg.version_mode || "bedrock-oss"; + // 自定义了版本列表地址或使用 endstone 时尊重原配置,不查官方接口 + if (versionMode === "bedrock-oss" && !customApi) { + const official = await fetchOfficialServerLink(channel, hostOs); + if (official) return { version: official.version, cdnRoot: "", downloadUrl: official.downloadUrl }; + } + let lastErr: unknown; for (let attempt = 1; attempt <= 3; attempt++) { try {