diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml index 1b3f700..8728ced 100644 --- a/.github/workflows/ci-check.yml +++ b/.github/workflows/ci-check.yml @@ -37,7 +37,7 @@ jobs: # 纯单测,不需要网络,稳定必过 - name: Unit tests - run: pnpm exec mocha -r intelli-espower-loader -t 60000 main.test.js --exit + run: pnpm exec mocha -r intelli-espower-loader -t 60000 main.test.js util/runtimeState.test.js --exit - name: Docs format check run: pnpm docs:check diff --git a/AGENTS.md b/AGENTS.md index d0eefb2..59b7d72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,11 +14,12 @@ - 打包独立二进制:`pnpm pkgwin` / `pkglinux` / `pkgmacos` ## 架构 -- `app.js`(也是 `bin`)——服务入口。先确保 `os.tmpdir()` 里存在 `anonymous_token`,执行 `generateConfig()` 刷新匿名 cookie 与 xeapi 公钥,再调用 `server.serveNcmApi()`。 +- `app.js`(也是 `bin`)——服务入口。执行 `generateConfig()` 预热运行时凭证(anonymous_token 与 xeapi 公钥,存于内存),再调用 `server.serveNcmApi()`。不依赖 `/tmp` 等可写文件系统。 - `server.js`——Express 工厂。`constructServer()` 自动扫描 `module/*.js`,每个文件注册一条路由(文件名 `_` 转 `/`,如 `album_new.js` → `/album/new`;特例 `daily_signin`/`fm_trash`/`personal_fm` 硬编码在 `server.js` 的 `special` 对象里)。`serveNcmApi()` 监听 `PORT`(默认 3000)/`HOST`。 - `main.js`——作为依赖被引入时的入口(`main` 字段)。把每个 `module/*` 导出为同名函数 `name(data)`,另导出 `server`、`serveNcmApi`、`getModulesDefinitions`。 - `module/*.js`——每个接口一个文件,标准写法:`module.exports = (query, request) => request(path, data, createOption(query))`。`createOption` 在 `util/option.js`,负责 crypto、cookie(回退到 `NETEASE_COOKIE`)、proxy、realIP/randomCNIP、headers、timeout。 -- `util/request.js`——唯一的对外 HTTP 层(axios)。按 `crypto`(`api`/`eapi`/`weapi`/`linuxapi`/`xeapi`)加密并设置 IP 头;在 require 时同步读取 `os.tmpdir()` 里的 `anonymous_token` 与 `xeapi_public_key`。 +- `util/request.js`——唯一的对外 HTTP 层(axios)。按 `crypto`(`api`/`eapi`/`weapi`/`linuxapi`/`xeapi`)加密并设置 IP 头;运行时凭证一律从 `util/runtimeState.js` 读取,不再有任何 require 期文件 I/O。 +- `util/runtimeState.js`——统一的进程内运行时状态(`anonymous_token` 与 `xeapi_public_key`)。默认纯内存、零文件 I/O;通过 `loadSeed()` 可选地从环境变量 `NETEASE_ANONYMOUS_TOKEN` / `NETEASE_XEAPI_PUBLIC_KEY` 或旧 `/tmp` 文件加载迁移种子。`util/credentials.js` 是唯一的网络刷新层(含并发去重与防重入,适配 serverless 冷启动)。 - `util/config.json`——运行时配置:网易域名 + `APP_CONF.encrypt: true`(默认走 eapi 加密)。已被 git 跟踪,改动会改变全局默认行为。 - `index.js` / `index.mjs`——`require('./app.js')` 的薄包装,供 Vercel(`vercel.json`)和 ESM 导入使用。 - `server.js` 里有一段非显而易见的逻辑:环境变量 `ENABLE_GENERAL_UNBLOCK=true` 时,`/song/url/v1` 的响应会被自动解灰(走 `@neteasecloudmusicapienhanced/unblockmusic-utils`);另外 `query.noCookie` 为真时不向响应写 `Set-Cookie`。 @@ -29,15 +30,15 @@ - 改文件名/路由会破坏已有客户端,尽量保持旧路径兼容。 ## 测试 -- `pnpm test` 跑 `server.test.js` + `main.test.js`。`server.test.js` 在 `before()` 里启动真实服务器,`test/*.test.js` 全部请求**真实网易云 API**——必须联网,且可能因上游风控/限流偶发失败。`main.test.js` 是纯单测,不需要网络。 +- `pnpm test` 跑 `server.test.js` + `main.test.js` + `util/runtimeState.test.js`。`server.test.js` 在 `before()` 里启动真实服务器,`test/*.test.js` 全部请求**真实网易云 API**——必须联网,且可能因上游风控/限流偶发失败。`main.test.js` 与 `util/runtimeState.test.js` 是纯单测,不需要网络。 - **CI 门禁只看稳定项**:`ci-check.yml` 在 Node 18/22/24 上跑 `pnpm lint` + `main.test.js` + `pnpm docs:check`;完整集成测试(`server.test.js`)是 `continue-on-error`,不阻塞合并。本地跑全量测试失败先怀疑上游风控。 - 测试使用 `power-assert`(经 `intelli-espower-loader`),普通 `assert` 写法也会输出详细 diff。 - 只跑单个用例:`pnpm exec mocha -r intelli-espower-loader -t 60000 --grep "" server.test.js main.test.js --exit` -- 只跑离线单测:`pnpm exec mocha -r intelli-espower-loader -t 60000 main.test.js --exit` +- 只跑离线单测:`pnpm exec mocha -r intelli-espower-loader -t 60000 main.test.js util/runtimeState.test.js --exit` ## 坑与注意 - **改 `package.json` 的 `version` 会触发自动发布**:`release-on-version-change.yml` 按 paths 过滤——只要 push 到 `main` 时 `package.json` 有改动且 version 变了,就自动打 tag + GitHub Release(`pkg` 三平台二进制)、推送 Docker 镜像(Docker Hub + GHCR 多架构)、`pnpm publish` 到 npm;tag 已存在则跳过。别顺手改版本号,也别往 `package.json` 里写无关改动凑提交。 - **没有实际 git hooks**:`package.json` 里配了 `lint-staged` 和 husky,但 `.husky/` 下只有 `_` 脚手架目录、没有真正的 hook 文件,commit 时不会自动跑任何检查,自己记得 `pnpm lint-fix`。 - **代理环境变量已失效**:README 里关于 `http_proxy`/`https_proxy` 的警告来自旧 `request` 库时代;现在 `util/request.js` 用 axios + 自定义 keep-alive agent,且显式 `proxy: false`,环境变量代理不会生效。按请求走 `query.proxy` 参数(支持 PAC 和 http 隧道)。 -- **启动令牌在系统临时目录**:`anonymous_token`、`xeapi_public_key` 存放在 `os.tmpdir()`,`util/request.js` 在 require 时同步读取。文件过期或被清空就重启服务(或调用 `generateConfig()`);首次启动先写空文件再刷新。 +- **运行时凭证在内存中**:`anonymous_token`、`xeapi_public_key` 存于 `util/runtimeState.js`(进程内),不再写 `/tmp`,可在只读文件系统 / Android / iOS / serverless 上运行。缺失时由 `util/credentials.js` 按需刷新;旧 `/tmp` 文件仅作为可选迁移种子(`loadSeed()`),环境变量 `NETEASE_ANONYMOUS_TOKEN` / `NETEASE_XEAPI_PUBLIC_KEY` 优先。重启服务(或调用 `generateConfig()`)会重新刷新。 - **ESLint 9 flat config**:`eslint.config.js`,风格由 `eslint-plugin-prettier` 强制(2 空格缩进、单引号、分号、`endOfLine: auto`)。 diff --git a/app.js b/app.js index 1c662e0..9516d26 100755 --- a/app.js +++ b/app.js @@ -1,14 +1,7 @@ #!/usr/bin/env node -const fs = require('fs') -const path = require('path') -const tmpPath = require('os').tmpdir() async function start() { - // 检测是否存在 anonymous_token 文件,没有则生成 - if (!fs.existsSync(path.resolve(tmpPath, 'anonymous_token'))) { - fs.writeFileSync(path.resolve(tmpPath, 'anonymous_token'), '', 'utf-8') - } - // 启动时更新anonymous_token + // 启动时预热运行时凭证(纯内存,不依赖 /tmp 等可写文件系统) const generateConfig = require('./generateConfig') await generateConfig() require('./server').serveNcmApi({ diff --git a/generateConfig.js b/generateConfig.js index 7640065..41d93bd 100644 --- a/generateConfig.js +++ b/generateConfig.js @@ -1,41 +1,31 @@ -const fs = require('fs') -const path = require('path') -const { register_anonimous } = require('./main') -const { cookieToJson, generateRandomChineseIP } = require('./util/index') -const { getXeapiPublicKey } = require('./util/xeapiKey') -const tmpPath = require('os').tmpdir() +const runtimeState = require('./util/runtimeState') +const { generateRandomChineseIP } = require('./util/index') +/** + * 启动/预热配置。 + * + */ async function generateConfig() { global.cnIp = generateRandomChineseIP() + + // 先加载可选种子,保证网络刷新失败时仍有可用值(迁移/降级)。 + runtimeState.loadSeed() + + // 启动时主动刷新匿名 token;失败则退回到种子(可能为空,由请求层按需兜底)。 try { - const res = await register_anonimous() - const cookie = res.body.cookie - if (cookie) { - const cookieObj = cookieToJson(cookie) - fs.writeFileSync( - path.resolve(tmpPath, 'anonymous_token'), - cookieObj.MUSIC_A, - 'utf-8', - ) - } + const { refreshAnonymousToken } = require('./util/credentials') + await refreshAnonymousToken() } catch (error) { console.log(error) } + try { - let currentPublicKey = {} - try { - currentPublicKey = JSON.parse( - fs.readFileSync(path.resolve(tmpPath, 'xeapi_public_key'), 'utf-8'), - ) - } catch (_) {} - const publicKey = await getXeapiPublicKey(currentPublicKey, global.deviceId) - fs.writeFileSync( - path.resolve(tmpPath, 'xeapi_public_key'), - JSON.stringify(publicKey), - 'utf-8', - ) + const { fetchXeapiPublicKey } = require('./util/credentials') + const publicKey = await fetchXeapiPublicKey() + runtimeState.mergeXeapiPublicKey(publicKey) } catch (error) { console.log(error) } } + module.exports = generateConfig diff --git a/main.js b/main.js index 6c7634b..8af6f54 100644 --- a/main.js +++ b/main.js @@ -1,13 +1,7 @@ const fs = require('fs') const path = require('path') -const tmpPath = require('os').tmpdir() const { cookieToJson } = require('./util') -const anonymousTokenPath = path.resolve(tmpPath, 'anonymous_token') -if (!fs.existsSync(anonymousTokenPath)) { - fs.writeFileSync(anonymousTokenPath, '', 'utf-8') -} - /** @type {Record} */ let obj = {} diff --git a/package.json b/package.json index 8643f7c..e5998e1 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "nodemon app.js", "start": "node app.js", - "test": "mocha -r intelli-espower-loader -t 60000 server.test.js main.test.js --exit", + "test": "mocha -r intelli-espower-loader -t 60000 server.test.js main.test.js util/runtimeState.test.js --exit", "lint": "eslint \"**/*.{js,ts}\"", "lint-fix": "eslint --fix \"**/*.{js,ts}\"", "prepare": "husky install", diff --git a/server.test.js b/server.test.js index ea46f23..2a997aa 100644 --- a/server.test.js +++ b/server.test.js @@ -1,11 +1,7 @@ const fs = require('fs') const path = require('path') -const tmpPath = require('os').tmpdir() /** @type {import("express").Express & serverMod.ExpressExtension} */ let app -if (!fs.existsSync(path.resolve(tmpPath, 'anonymous_token'))) { - fs.writeFileSync(path.resolve(tmpPath, 'anonymous_token'), '', 'utf-8') -} const serverMod = require('./server') before(async () => { const generateConfig = require('./generateConfig') diff --git a/util/credentials.js b/util/credentials.js new file mode 100644 index 0000000..4b93ea8 --- /dev/null +++ b/util/credentials.js @@ -0,0 +1,93 @@ +/** + * 凭证刷新层:把“如何获取”与“状态存放”解耦。 + * + * - util/runtimeState.js 负责进程内状态的存取(纯内存、并发去重); + * - 本模块负责实际的网络获取,并在成功后写入 runtimeState。 + * + * 两者分离后,runtimeState 可被纯单测覆盖(注入 mock 刷新函数), + * 而本模块是唯一允许 util -> module 依赖的地方。 + */ + +const runtimeState = require('./runtimeState') +const registerXeapiKey = require('../module/register_xeapikey') +const { cookieToJson } = require('./index') + +// 惰性加载 request,规避 util/request -> credentials -> request 的启动期循环依赖。 +// runtimeState 与本模块均不在 require 阶段触发网络或文件 I/O。 +let requestModule = null +const loadRequest = () => { + if (!requestModule) { + requestModule = require('./request') + } + return requestModule +} + +/** + * 通过网络注册匿名账号,获取并写入 anonymous_token。 + * @returns {Promise} 最新 token + */ +const refreshAnonymousToken = async () => { + const { register_anonimous } = require('../module/register_anonimous') + const res = await register_anonimous({}, loadRequest()) + const cookie = res && res.body ? res.body.cookie : '' + if (!cookie) { + throw new Error('anonymous token response missing cookie') + } + const cookieObj = cookieToJson(cookie) + const token = cookieObj.MUSIC_A + if (!token) { + throw new Error('anonymous token response missing MUSIC_A') + } + runtimeState.setAnonymousToken(token) + return token +} + +/** + * 获取 xeapi public key(纯获取,不写状态——由 runtimeState 统一合并/写入)。 + * + * 兼容 serverless:内存缺失时由调用方触发;若服务端未下发 version, + * 沿用旧状态里的 version,避免无谓的重取判定。 + * + * @returns {Promise>} + */ +const fetchXeapiPublicKey = async () => { + const current = runtimeState.getXeapiPublicKey() || {} + const result = await registerXeapiKey( + { + deviceId: global.deviceId, + currentKeyVersion: current.version || '', + }, + null, + ) + const publicKey = result && result.body ? result.body : {} + if (!publicKey.sk && !current.sk) { + throw new Error('xeapi public key response missing sk') + } + if (!publicKey.version && current.version) { + publicKey.version = current.version + } + return publicKey +} + +/** + * 确保 anonymous_token 可用(缺失时刷新)。 + * @returns {Promise} + */ +const ensureAnonymousToken = () => + runtimeState.ensureAnonymousToken(refreshAnonymousToken) + +/** + * 确保 xeapi public key 可用。 + * + * @param {{ force?: boolean }} [options] + * @returns {Promise | null>} + */ +const ensureXeapiPublicKey = (options = {}) => + runtimeState.ensureXeapiPublicKey(fetchXeapiPublicKey, options) + +module.exports = { + refreshAnonymousToken, + fetchXeapiPublicKey, + ensureAnonymousToken, + ensureXeapiPublicKey, +} diff --git a/util/request.js b/util/request.js index 55b6d2b..118a7cd 100644 --- a/util/request.js +++ b/util/request.js @@ -7,9 +7,7 @@ const { PacProxyAgent } = require('pac-proxy-agent') const http = require('http') const https = require('https') const tunnel = require('tunnel') -const fs = require('fs') -const path = require('path') -const tmpPath = require('os').tmpdir() +const runtimeState = require('./runtimeState') const { cookieToJson, cookieObjToString, @@ -25,26 +23,6 @@ const { getToken: antiCheatTokenV3, } = require('../module/register_checktoken_v3') -// 预先读取匿名token并缓存 -const anonymous_token = fs.readFileSync( - path.resolve(tmpPath, './anonymous_token'), - 'utf-8', -) -const xeapiPublicKeyPath = path.resolve(tmpPath, './xeapi_public_key') -let xeapi_public_key = null -const loadXeapiPublicKey = () => { - if (!xeapi_public_key && fs.existsSync(xeapiPublicKeyPath)) { - try { - xeapi_public_key = JSON.parse( - fs.readFileSync(xeapiPublicKeyPath, 'utf-8'), - ) - } catch (error) { - console.log('[ERR]', error) - } - } - return xeapi_public_key -} - // 预先绑定常用函数和常量 const floor = Math.floor const random = Math.random @@ -169,7 +147,8 @@ const processCookieObject = (cookie, crypto) => { } if (!processedCookie.MUSIC_U) { - processedCookie.MUSIC_A = processedCookie.MUSIC_A || anonymous_token + processedCookie.MUSIC_A = + processedCookie.MUSIC_A || runtimeState.getAnonymousToken() } return processedCookie @@ -209,6 +188,42 @@ const createRequest = async (uri, data, options) => { break } + // 懒加载匿名 token:仅当内存中缺失时刷新一次,失败不阻塞本次请求。 + // 兼容 serverless 冷启动——不假设启动阶段已刷新过。 + // 刷新类请求自身跳过(否则会等待自己造成死锁)。 + if ( + !runtimeState.getAnonymousToken() && + !runtimeState.isAnonymousTokenRefreshing() + ) { + try { + const { ensureAnonymousToken } = require('./credentials') + await ensureAnonymousToken() + } catch (error) { + console.log('[ERR]', error) + } + } + + // 懒加载 xeapi public key:仅在本次请求确实使用 xeapi 加密、且内存缺失时刷新。 + // 必须在进入同步 executor 前完成,因为加密过程需要同步拿到本状态。 + const resolvedCrypto = + options.crypto === '' || options.crypto === undefined + ? APP_CONF.encrypt + ? 'eapi' + : 'api' + : options.crypto + if ( + resolvedCrypto === 'xeapi' && + !runtimeState.getXeapiPublicKey() && + !runtimeState.isXeapiPublicKeyRefreshing() + ) { + try { + const { ensureXeapiPublicKey } = require('./credentials') + await ensureXeapiPublicKey() + } catch (error) { + console.log('[ERR]', error) + } + } + return new Promise((resolve, reject) => { // 变量声明和初始化 const headers = options.headers ? { ...options.headers } : {} @@ -273,7 +288,7 @@ const createRequest = async (uri, data, options) => { break case 'xeapi': - const xeapiPublicKey = loadXeapiPublicKey() + const xeapiPublicKey = runtimeState.getXeapiPublicKey() if (!xeapiPublicKey) { throw new Error('xeapi public key is missing') } diff --git a/util/runtimeState.js b/util/runtimeState.js new file mode 100644 index 0000000..2097c04 --- /dev/null +++ b/util/runtimeState.js @@ -0,0 +1,261 @@ +/** + * 统一运行时状态容器。 + * + * 历史上 anonymous_token / xeapi_public_key 以“文件当 IPC”的方式存放在 + * os.tmpdir()(/tmp)。这在可移植性上存在两个问题: + * 1. 并非所有平台都提供可写的 /tmp(如 Android / iOS / 嵌入式 / serverless); + * 2. main.js / request.js 会在 require 阶段就执行文件 I/O,只读文件系统上直接 + * 导致模块加载失败。 + * + * 但这些值本质上只是进程内的动态配置,读写双方都在同一个进程里,完全没有 + * 落盘的必要。本模块把它们收敛到统一的内存状态: + * - 默认纯内存,get/set 绝不做任何文件 I/O(可在只读 FS / serverless 安全使用); + * - 可选地通过 loadSeed() 从环境变量或旧 /tmp 文件读取“种子”,仅用于平滑迁移; + * - 支持懒加载与按需刷新,适配 serverless 冷启动(不假设启动时已刷新过)。 + */ + +const fs = require('fs') +const path = require('path') +const os = require('os') + +/** @type {string} */ +let anonymousToken = '' +/** @type {Record | null} */ +let xeapiPublicKey = null + +/** 进行中的刷新 Promise,用于并发去重 */ +let anonymousTokenTask = null +let xeapiPublicKeyTask = null + +/** + * 防重入标记:刷新凭证本身也要走 util/request 发 HTTP 请求, + * 若不跳过 request 层的“缺失即刷新”逻辑,会等待自己造成死锁。 + */ +let anonymousTokenRefreshing = false +let xeapiPublicKeyRefreshing = false + +/** 旧版本遗留的磁盘路径,仅作为可选迁移种子读取,永不写入 */ +const legacyTmpPath = (() => { + try { + return os.tmpdir() + } catch (_) { + return '' + } +})() + +const safeReadFile = (filePath) => { + if (!filePath) return null + try { + return fs.readFileSync(filePath, 'utf-8') + } catch (_) { + // 文件不存在 / 不可读 / 只读文件系统:静默降级 + return null + } +} + +/** + * 读取 anonymous_token 的可选种子(环境变量优先,其次旧 /tmp 文件)。 + * 仅在显式调用时读取,绝不在 require 阶段触发。 + * @returns {string} + */ +const readAnonymousTokenSeed = () => { + const fromEnv = process.env.NETEASE_ANONYMOUS_TOKEN + if (fromEnv) return fromEnv.trim() + + const fromFile = safeReadFile( + legacyTmpPath ? path.resolve(legacyTmpPath, 'anonymous_token') : '', + ) + return fromFile ? fromFile.trim() : '' +} + +/** + * 读取 xeapi_public_key 的可选种子(环境变量优先,其次旧 /tmp 文件)。 + * @returns {Record | null} + */ +const readXeapiPublicKeySeed = () => { + const fromEnv = process.env.NETEASE_XEAPI_PUBLIC_KEY + const raw = + fromEnv || + safeReadFile( + legacyTmpPath ? path.resolve(legacyTmpPath, 'xeapi_public_key') : '', + ) + if (!raw) return null + try { + return JSON.parse(raw) + } catch (_) { + return null + } +} + +/** + * 获取当前 anonymous_token(纯内存,不触发任何 I/O)。 + * @returns {string} + */ +const getAnonymousToken = () => anonymousToken + +/** + * 写入 anonymous_token。 + * @param {string} token + */ +const setAnonymousToken = (token) => { + if (typeof token === 'string' && token) { + anonymousToken = token + } +} + +/** + * 确保 anonymous_token 可用:优先返回内存值,缺失时刷新一次。 + * 并发调用会共享同一个刷新任务,避免重复请求。 + * @param {() => Promise} refresh 刷新函数,返回新的 token + * @returns {Promise} + */ +const ensureAnonymousToken = async (refresh) => { + const cached = getAnonymousToken() + if (cached) return cached + if (typeof refresh !== 'function') return cached + + if (!anonymousTokenTask) { + anonymousTokenRefreshing = true + anonymousTokenTask = Promise.resolve() + .then(refresh) + .then((token) => { + setAnonymousToken(token) + return token || '' + }) + .finally(() => { + anonymousTokenTask = null + anonymousTokenRefreshing = false + }) + } + return anonymousTokenTask +} + +/** + * 获取当前 xeapi public key 状态(纯内存,不触发任何 I/O)。 + * @returns {Record | null} + */ +const getXeapiPublicKey = () => xeapiPublicKey + +/** + * 写入 xeapi public key 状态。 + * @param {Record} publicKey + */ +const setXeapiPublicKey = (publicKey) => { + if (publicKey && typeof publicKey === 'object') { + xeapiPublicKey = publicKey + } +} + +/** + * 合并并写入 xeapi public key,保留旧状态里的 sk(服务端并非每次下发 sk)。 + * @param {Record} publicKey + * @returns {Record} + */ +const mergeXeapiPublicKey = (publicKey) => { + const previous = getXeapiPublicKey() || {} + const merged = { ...publicKey } + if (!merged.sk && previous.sk) { + merged.sk = previous.sk + } + xeapiPublicKey = merged + return merged +} + +/** + * 确保 xeapi public key 可用。 + * + * 语义:有缓存且未声明 force 时直接复用;否则调用 refresh 刷新,并发去重。 + * “version 是否变化”由调用方判断并通过 options.force 表达。 + * + * @param {() => Promise>} refresh 刷新函数,返回新的 public key 状态 + * @param {{ force?: boolean }} [options] + * @returns {Promise | null>} + */ +const ensureXeapiPublicKey = async (refresh, options = {}) => { + const cached = getXeapiPublicKey() + if (cached && !options.force) { + return cached + } + if (typeof refresh !== 'function') { + return cached + } + + if (!xeapiPublicKeyTask) { + xeapiPublicKeyRefreshing = true + xeapiPublicKeyTask = Promise.resolve() + .then(refresh) + .then((result) => mergeXeapiPublicKey(result || {})) + .finally(() => { + xeapiPublicKeyTask = null + xeapiPublicKeyRefreshing = false + }) + } + return xeapiPublicKeyTask +} + +/** + * 刷新类请求是否正在进行中。request 层据此跳过“缺失即刷新”,避免死锁。 + * @returns {boolean} + */ +const isRefreshing = () => anonymousTokenRefreshing || xeapiPublicKeyRefreshing + +/** + * 匿名 token 刷新是否进行中。 + * @returns {boolean} + */ +const isAnonymousTokenRefreshing = () => anonymousTokenRefreshing + +/** + * xeapi public key 刷新是否进行中。 + * @returns {boolean} + */ +const isXeapiPublicKeyRefreshing = () => xeapiPublicKeyRefreshing + +/** + * 清空内存状态。仅用于测试,或需要强制重新初始化的场景。 + */ +const reset = () => { + anonymousToken = '' + xeapiPublicKey = null + anonymousTokenTask = null + xeapiPublicKeyTask = null + anonymousTokenRefreshing = false + xeapiPublicKeyRefreshing = false +} + +/** + * 从环境变量 / 旧 /tmp 文件加载可选种子到内存。 + * 默认不启用——由 app/generateConfig 等启动路径显式调用; + * 测试与只读 FS 场景可以不调用,从而完全避免文件 I/O。 + * + * @returns {{ anonymousToken: string, xeapiPublicKey: Record | null }} + */ +const loadSeed = () => { + const token = readAnonymousTokenSeed() + if (token && !anonymousToken) { + anonymousToken = token + } + const publicKey = readXeapiPublicKeySeed() + if (publicKey && !xeapiPublicKey) { + xeapiPublicKey = publicKey + } + return { anonymousToken, xeapiPublicKey } +} + +module.exports = { + getAnonymousToken, + setAnonymousToken, + ensureAnonymousToken, + getXeapiPublicKey, + setXeapiPublicKey, + mergeXeapiPublicKey, + ensureXeapiPublicKey, + isRefreshing, + isAnonymousTokenRefreshing, + isXeapiPublicKeyRefreshing, + loadSeed, + reset, + // 供 generateConfig 等迁移逻辑按需加载种子 + readAnonymousTokenSeed, + readXeapiPublicKeySeed, +} diff --git a/util/runtimeState.test.js b/util/runtimeState.test.js new file mode 100644 index 0000000..e1a8284 --- /dev/null +++ b/util/runtimeState.test.js @@ -0,0 +1,235 @@ +const assert = require('assert') + +describe('util/runtimeState', () => { + let runtimeState + + beforeEach(() => { + // 每个用例从干净状态开始,并清掉可能影响 seed 的环境变量 + delete process.env.NETEASE_ANONYMOUS_TOKEN + delete process.env.NETEASE_XEAPI_PUBLIC_KEY + delete require.cache[require.resolve('./runtimeState')] + runtimeState = require('./runtimeState') + }) + + afterEach(() => { + delete process.env.NETEASE_ANONYMOUS_TOKEN + delete process.env.NETEASE_XEAPI_PUBLIC_KEY + }) + + describe('anonymous token', () => { + it('set/get 往返', () => { + runtimeState.setAnonymousToken('TOKEN_A') + assert.strictEqual(runtimeState.getAnonymousToken(), 'TOKEN_A') + }) + + it('set 忽略空值,不覆盖已有 token', () => { + runtimeState.setAnonymousToken('TOKEN_A') + runtimeState.setAnonymousToken('') + runtimeState.setAnonymousToken(null) + assert.strictEqual(runtimeState.getAnonymousToken(), 'TOKEN_A') + }) + + it('优先从环境变量读取种子(需显式 loadSeed)', () => { + process.env.NETEASE_ANONYMOUS_TOKEN = 'ENV_TOKEN' + assert.strictEqual(runtimeState.getAnonymousToken(), '') + runtimeState.loadSeed() + assert.strictEqual(runtimeState.getAnonymousToken(), 'ENV_TOKEN') + }) + + it('缺失时 ensureAnonymousToken 调用 refresh 并写入', async () => { + let calls = 0 + const token = await runtimeState.ensureAnonymousToken(async () => { + calls++ + return 'FRESH_TOKEN' + }) + assert.strictEqual(token, 'FRESH_TOKEN') + assert.strictEqual(runtimeState.getAnonymousToken(), 'FRESH_TOKEN') + assert.strictEqual(calls, 1) + }) + + it('已有 token 时 ensureAnonymousToken 不再调用 refresh', async () => { + runtimeState.setAnonymousToken('CACHED') + let calls = 0 + const token = await runtimeState.ensureAnonymousToken(async () => { + calls++ + return 'FRESH' + }) + assert.strictEqual(token, 'CACHED') + assert.strictEqual(calls, 0) + }) + + it('并发调用共享同一个刷新任务(去重)', async () => { + let calls = 0 + const slowRefresh = () => + new Promise((resolve) => + setTimeout(() => { + calls++ + resolve('SHARED_TOKEN') + }, 20), + ) + + const results = await Promise.all([ + runtimeState.ensureAnonymousToken(slowRefresh), + runtimeState.ensureAnonymousToken(slowRefresh), + runtimeState.ensureAnonymousToken(slowRefresh), + ]) + + assert.deepStrictEqual(results, [ + 'SHARED_TOKEN', + 'SHARED_TOKEN', + 'SHARED_TOKEN', + ]) + assert.strictEqual(calls, 1) + }) + + it('refresh 失败后任务被清理,可再次重试', async () => { + await assert.rejects( + runtimeState.ensureAnonymousToken(async () => { + throw new Error('boom') + }), + ) + assert.strictEqual(runtimeState.isAnonymousTokenRefreshing(), false) + + const token = await runtimeState.ensureAnonymousToken( + async () => 'RETRY_OK', + ) + assert.strictEqual(token, 'RETRY_OK') + }) + + it('刷新期间 isAnonymousTokenRefreshing 为 true', async () => { + let sawRefreshing = false + await runtimeState.ensureAnonymousToken(async () => { + sawRefreshing = runtimeState.isAnonymousTokenRefreshing() + return 'X' + }) + assert.strictEqual(sawRefreshing, true) + assert.strictEqual(runtimeState.isAnonymousTokenRefreshing(), false) + }) + }) + + describe('xeapi public key', () => { + it('set/get 往返', () => { + runtimeState.setXeapiPublicKey({ publicKey: 'PK', version: 'v1' }) + assert.deepStrictEqual(runtimeState.getXeapiPublicKey(), { + publicKey: 'PK', + version: 'v1', + }) + }) + + it('优先从环境变量读取 JSON 种子(需显式 loadSeed)', () => { + process.env.NETEASE_XEAPI_PUBLIC_KEY = JSON.stringify({ + publicKey: 'ENV_PK', + version: 'env-v', + }) + assert.strictEqual(runtimeState.getXeapiPublicKey(), null) + runtimeState.loadSeed() + assert.deepStrictEqual(runtimeState.getXeapiPublicKey(), { + publicKey: 'ENV_PK', + version: 'env-v', + }) + }) + + it('环境变量中的非法 JSON 被忽略', () => { + process.env.NETEASE_XEAPI_PUBLIC_KEY = 'not-json' + runtimeState.loadSeed() + assert.strictEqual(runtimeState.getXeapiPublicKey(), null) + }) + + it('mergeXeapiPublicKey 保留旧 sk(服务端未下发时)', () => { + runtimeState.setXeapiPublicKey({ publicKey: 'PK1', sk: 'SK1' }) + const merged = runtimeState.mergeXeapiPublicKey({ publicKey: 'PK2' }) + assert.strictEqual(merged.sk, 'SK1') + assert.strictEqual(merged.publicKey, 'PK2') + }) + + it('mergeXeapiPublicKey 优先使用新 sk', () => { + runtimeState.setXeapiPublicKey({ publicKey: 'PK1', sk: 'SK1' }) + const merged = runtimeState.mergeXeapiPublicKey({ + publicKey: 'PK2', + sk: 'SK2', + }) + assert.strictEqual(merged.sk, 'SK2') + }) + + it('缺失时 ensureXeapiPublicKey 调用 refresh 并合并写入', async () => { + let calls = 0 + const result = await runtimeState.ensureXeapiPublicKey(async () => { + calls++ + return { publicKey: 'FRESH_PK', version: 'v2' } + }) + assert.strictEqual(calls, 1) + assert.deepStrictEqual(result, { publicKey: 'FRESH_PK', version: 'v2' }) + assert.deepStrictEqual(runtimeState.getXeapiPublicKey(), { + publicKey: 'FRESH_PK', + version: 'v2', + }) + }) + + it('已有 key 且未 force 时不刷新', async () => { + runtimeState.setXeapiPublicKey({ publicKey: 'CACHED' }) + let calls = 0 + const result = await runtimeState.ensureXeapiPublicKey(async () => { + calls++ + return { publicKey: 'FRESH' } + }) + assert.strictEqual(calls, 0) + assert.strictEqual(result.publicKey, 'CACHED') + }) + + it('force 时即使已有 key 也刷新', async () => { + runtimeState.setXeapiPublicKey({ publicKey: 'CACHED', sk: 'OLD_SK' }) + let calls = 0 + const result = await runtimeState.ensureXeapiPublicKey( + async () => { + calls++ + return { publicKey: 'FRESH' } + }, + { force: true }, + ) + assert.strictEqual(calls, 1) + assert.strictEqual(result.publicKey, 'FRESH') + // 旧 sk 被保留 + assert.strictEqual(result.sk, 'OLD_SK') + }) + + it('并发 ensureXeapiPublicKey 共享同一刷新任务', async () => { + let calls = 0 + const slowRefresh = () => + new Promise((resolve) => + setTimeout(() => { + calls++ + resolve({ publicKey: 'SHARED_PK' }) + }, 20), + ) + + const results = await Promise.all([ + runtimeState.ensureXeapiPublicKey(slowRefresh), + runtimeState.ensureXeapiPublicKey(slowRefresh), + ]) + + assert.strictEqual(calls, 1) + assert.strictEqual(results[0].publicKey, 'SHARED_PK') + assert.strictEqual(results[1].publicKey, 'SHARED_PK') + }) + + it('刷新期间 isXeapiPublicKeyRefreshing 为 true', async () => { + let sawRefreshing = false + await runtimeState.ensureXeapiPublicKey(async () => { + sawRefreshing = runtimeState.isXeapiPublicKeyRefreshing() + return { publicKey: 'X' } + }) + assert.strictEqual(sawRefreshing, true) + assert.strictEqual(runtimeState.isXeapiPublicKeyRefreshing(), false) + }) + }) + + describe('reset', () => { + it('清空 token 与 publicKey', () => { + runtimeState.setAnonymousToken('TOKEN') + runtimeState.setXeapiPublicKey({ publicKey: 'PK' }) + runtimeState.reset() + assert.strictEqual(runtimeState.getAnonymousToken(), '') + assert.strictEqual(runtimeState.getXeapiPublicKey(), null) + }) + }) +})