diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml index 8728ced..1b3f700 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 util/runtimeState.test.js --exit + run: pnpm exec mocha -r intelli-espower-loader -t 60000 main.test.js --exit - name: Docs format check run: pnpm docs:check diff --git a/.gitignore b/.gitignore index ebd06f5..599fe8e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ node_modules .history examples/moddef.json bin -anonymous_token .vercel .env IFLOW.md diff --git a/AGENTS.md b/AGENTS.md index 59b7d72..e69d0d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,12 +14,12 @@ - 打包独立二进制:`pnpm pkgwin` / `pkglinux` / `pkgmacos` ## 架构 -- `app.js`(也是 `bin`)——服务入口。执行 `generateConfig()` 预热运行时凭证(anonymous_token 与 xeapi 公钥,存于内存),再调用 `server.serveNcmApi()`。不依赖 `/tmp` 等可写文件系统。 +- `app.js`(也是 `bin`)——服务入口。执行 `generateConfig()` 预热运行时凭证(匿名令牌与 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`。 +- `main.js`——作为依赖被引入时的入口(`main` 字段)。把每个 `module/*` 导出为同名函数 `name(data)`,另导出 `server`、`serveNcmApi`、`getModulesDefinitions`、`generateConfig`(库用户可 `await main.generateConfig()` 预热/续期凭证;不调用也行,首个请求会在凭证缺失时自动引导)。 - `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 头;运行时凭证一律从 `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/request.js`——唯一的对外 HTTP 层(axios)。按 `crypto`(`api`/`eapi`/`weapi`/`linuxapi`/`xeapi`)加密并设置 IP 头;匿名令牌与 xeapi 公钥每次请求实时读取 `util/runtimeState.js`,缺失时按需引导(并发共享同一刷新任务,刷新自身的请求带 `skipTokenEnsure` 防自等待死锁)。另导出 `refreshAnonymousToken` / `refreshXeapiPublicKey` 供 `generateConfig` 复用。 +- `util/runtimeState.js`——统一的进程内运行时动态状态(`anonymousToken` / `xeapiPublicKey`)。纯内存、零文件 I/O;`register_anonimous` / `register_xeapikey` 成功后写入,`util/request.js` 读取,`ensure()` 提供缺失时按需刷新。以后新增动态配置放这里,不要再往 `os.tmpdir()` 写文件。 - `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`。 @@ -30,15 +30,15 @@ - 改文件名/路由会破坏已有客户端,尽量保持旧路径兼容。 ## 测试 -- `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` 是纯单测,不需要网络。 +- `pnpm test` 跑 `server.test.js` + `main.test.js`。`server.test.js` 在 `before()` 里启动真实服务器,`test/*.test.js` 全部请求**真实网易云 API**——必须联网,且可能因上游风控/限流偶发失败。`main.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 util/runtimeState.test.js --exit` +- 只跑离线单测:`pnpm exec mocha -r intelli-espower-loader -t 60000 main.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` 存于 `util/runtimeState.js`(进程内),不再写 `/tmp`,可在只读文件系统 / Android / iOS / serverless 上运行。缺失时由 `util/credentials.js` 按需刷新;旧 `/tmp` 文件仅作为可选迁移种子(`loadSeed()`),环境变量 `NETEASE_ANONYMOUS_TOKEN` / `NETEASE_XEAPI_PUBLIC_KEY` 优先。重启服务(或调用 `generateConfig()`)会重新刷新。 +- **运行时凭证在内存(runtimeState)**:`anonymous_token` 与 `xeapi_public_key` 存于 `util/runtimeState.js`,不做任何文件 I/O(历史上曾以文件形式放 `os.tmpdir()`,为可移植性已移除——只读 FS / Android / iOS / serverless 均可运行)。启动时 `generateConfig()` 先取 xeapi 公钥、再注册匿名令牌(顺序不能反,后者依赖前者);运行中过期可再调 `generateConfig()` 或对应 `register_*` 接口,立即生效、无需重启。 - **ESLint 9 flat config**:`eslint.config.js`,风格由 `eslint-plugin-prettier` 强制(2 空格缩进、单引号、分号、`endOfLine: auto`)。 diff --git a/app.js b/app.js index 9516d26..2a3701d 100755 --- a/app.js +++ b/app.js @@ -1,7 +1,7 @@ #!/usr/bin/env node async function start() { - // 启动时预热运行时凭证(纯内存,不依赖 /tmp 等可写文件系统) + // 启动时预热运行时凭证(匿名令牌 / xeapi 公钥,均存于内存中的 runtimeState) const generateConfig = require('./generateConfig') await generateConfig() require('./server').serveNcmApi({ diff --git a/generateConfig.js b/generateConfig.js index 41d93bd..6b6c3a0 100644 --- a/generateConfig.js +++ b/generateConfig.js @@ -1,28 +1,27 @@ -const runtimeState = require('./util/runtimeState') -const { generateRandomChineseIP } = require('./util/index') +const request = require('./util/request') +const { generateRandomChineseIP, generateDeviceId } = require('./util/index') /** - * 启动/预热配置。 + * 启动 / 手动刷新运行时动态配置。 * + * 凭证均存于内存(util/runtimeState.js),不做任何文件 I/O: + * - 设备 ID 先行 —— xeapi 公钥注册需携带 deviceId(服务端对无 + * deviceId 的请求不下发 sk),匿名注册也复用同一设备身份; + * - xeapi 公钥其次 —— 注册匿名令牌走 xeapi 加密,依赖该公钥; + * - 匿名令牌最后注册,三者就绪后冷启动一次即可完成引导。 */ async function generateConfig() { global.cnIp = generateRandomChineseIP() - - // 先加载可选种子,保证网络刷新失败时仍有可用值(迁移/降级)。 - runtimeState.loadSeed() - - // 启动时主动刷新匿名 token;失败则退回到种子(可能为空,由请求层按需兜底)。 + if (!global.deviceId) { + global.deviceId = generateDeviceId() + } try { - const { refreshAnonymousToken } = require('./util/credentials') - await refreshAnonymousToken() + await request.refreshXeapiPublicKey() } catch (error) { console.log(error) } - try { - const { fetchXeapiPublicKey } = require('./util/credentials') - const publicKey = await fetchXeapiPublicKey() - runtimeState.mergeXeapiPublicKey(publicKey) + await request.refreshAnonymousToken() } catch (error) { console.log(error) } diff --git a/main.js b/main.js index 8af6f54..2fcacdb 100644 --- a/main.js +++ b/main.js @@ -40,6 +40,7 @@ moduleFiles.forEach((file) => { }) let serverModule = null +let generateConfigModule = null /** * @type {Record & import("./server")} @@ -51,6 +52,17 @@ module.exports = { } return serverModule }, + /** + * 刷新运行时动态配置(匿名令牌 / xeapi 公钥,存于内存 runtimeState)。 + * 作为依赖使用时可以不调用——首个请求会在凭证缺失时自动引导; + * 需要预热或手动续期时再 `await main.generateConfig()`。 + */ + get generateConfig() { + if (!generateConfigModule) { + generateConfigModule = require('./generateConfig') + } + return generateConfigModule + }, ...obj, } diff --git a/main.test.js b/main.test.js index 2a7a6ca..4ca5565 100644 --- a/main.test.js +++ b/main.test.js @@ -16,3 +16,63 @@ describe('methods in module', () => { assert.strictEqual(typeof main.activate_init_profile, 'function') }) }) + +describe('runtimeState', () => { + const runtimeState = require('./util/runtimeState') + + it('has generateConfig for bootstrapping in-memory credentials', () => { + assert.strictEqual(typeof main.generateConfig, 'function') + }) + + it('exposes anonymousToken / xeapiPublicKey in memory', () => { + assert.ok('anonymousToken' in runtimeState) + assert.ok('xeapiPublicKey' in runtimeState) + }) + + describe('ensure()', () => { + it('returns existing value without refreshing', async () => { + runtimeState.ensureProbe = 'ready' + let called = 0 + const value = await runtimeState.ensure('ensureProbe', async () => { + called++ + }) + assert.strictEqual(value, 'ready') + assert.strictEqual(called, 0) + delete runtimeState.ensureProbe + }) + + it('refreshes when missing and shares one task among concurrent calls', async () => { + let refreshes = 0 + const refresh = () => + new Promise((resolve) => + setTimeout(() => { + refreshes++ + runtimeState.ensureProbe = 'fresh' + resolve() + }, 20), + ) + const results = await Promise.all([ + runtimeState.ensure('ensureProbe', refresh), + runtimeState.ensure('ensureProbe', refresh), + runtimeState.ensure('ensureProbe', refresh), + ]) + assert.deepStrictEqual(results, ['fresh', 'fresh', 'fresh']) + assert.strictEqual(refreshes, 1) + delete runtimeState.ensureProbe + }) + + it('propagates refresh failure and allows retry', async () => { + let attempts = 0 + const fail = async () => { + attempts++ + throw new Error('boom') + } + await assert.rejects(runtimeState.ensure('ensureProbe', fail), /boom/) + assert.strictEqual(attempts, 1) + // 失败后任务被清理,下一次调用会重试 + await assert.rejects(runtimeState.ensure('ensureProbe', fail), /boom/) + assert.strictEqual(attempts, 2) + delete runtimeState.ensureProbe + }) + }) +}) diff --git a/module/register_anonimous.js b/module/register_anonimous.js index ae76ad9..c09ad1f 100644 --- a/module/register_anonimous.js +++ b/module/register_anonimous.js @@ -7,7 +7,8 @@ const ID_XOR_KEY_1 = '3go8&$8*3*3h0k(2)2' const logger = require('../util/logger.js') const createOption = require('../util/option.js') -const { generateDeviceId } = require('../util/index') +const { generateDeviceId, cookieToJson } = require('../util/index') +const runtimeState = require('../util/runtimeState') // function getRandomFromList(list) { // return list[Math.floor(Math.random() * list.length)] @@ -25,7 +26,9 @@ function cloudmusic_dll_encode_id(some_id) { } module.exports = async (query, request) => { - const deviceId = generateDeviceId() + // 复用进程内已有的设备身份(真实客户端的设备 ID 是稳定的); + // xeapi 公钥注册同样依赖该 deviceId + const deviceId = global.deviceId || generateDeviceId() logger.info(`Successfully registered anonimous token, deviceId: ${deviceId}`) global.deviceId = deviceId const encodedId = CryptoJS.enc.Base64.stringify( @@ -42,6 +45,11 @@ module.exports = async (query, request) => { createOption(query, 'xeapi'), ) if (result.body.code === 200) { + // 注册成功后立即写入运行时状态,后续请求直接使用新令牌(无需重启) + const cookieObj = cookieToJson(result.cookie.join(';')) + if (cookieObj.MUSIC_A) { + runtimeState.anonymousToken = cookieObj.MUSIC_A + } result = { status: 200, body: { diff --git a/module/register_xeapikey.js b/module/register_xeapikey.js index 48e0953..c92b7ed 100644 --- a/module/register_xeapikey.js +++ b/module/register_xeapikey.js @@ -1,6 +1,7 @@ const { default: axios } = require('axios') const encrypt = require('../util/crypto') const { APP_CONF } = require('../util/config.json') +const runtimeState = require('../util/runtimeState') const generateNonce = () => { let nonce = '' @@ -63,6 +64,9 @@ module.exports = async (query, request) => { throw new Error('xeapi public key response missing sk') } + // 获取成功后立即写入运行时状态,供 util/request.js 的 xeapi 请求使用 + runtimeState.xeapiPublicKey = { ...publicKey, deviceId } + return { status: 200, body: { diff --git a/package.json b/package.json index e5998e1..8643f7c 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 util/runtimeState.test.js --exit", + "test": "mocha -r intelli-espower-loader -t 60000 server.test.js main.test.js --exit", "lint": "eslint \"**/*.{js,ts}\"", "lint-fix": "eslint --fix \"**/*.{js,ts}\"", "prepare": "husky install", diff --git a/util/credentials.js b/util/credentials.js deleted file mode 100644 index 4b93ea8..0000000 --- a/util/credentials.js +++ /dev/null @@ -1,93 +0,0 @@ -/** - * 凭证刷新层:把“如何获取”与“状态存放”解耦。 - * - * - 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 118a7cd..75c5bcd 100644 --- a/util/request.js +++ b/util/request.js @@ -148,7 +148,7 @@ const processCookieObject = (cookie, crypto) => { if (!processedCookie.MUSIC_U) { processedCookie.MUSIC_A = - processedCookie.MUSIC_A || runtimeState.getAnonymousToken() + processedCookie.MUSIC_A || runtimeState.anonymousToken } return processedCookie @@ -175,6 +175,33 @@ const generateRequestId = () => { .padStart(4, '0')}` } +// ---- 运行时凭证的强制刷新 ---- +// 匿名令牌与 xeapi 公钥存于 util/runtimeState(纯内存、零文件 I/O), +// 由对应的 register_* 模块在成功获取后写入,此处只负责编排调用。 + +// 刷新自身的请求带 skipTokenEnsure 标记:跳过下方匿名令牌的按需引导, +// 避免“刷新任务等待自己”的死锁(注册匿名账号本就不依赖旧令牌) +const refreshTokenRequest = (uri, data, options = {}) => + createRequest(uri, data, { ...options, skipTokenEnsure: true }) + +// 强制刷新匿名令牌(成功后由 register_anonimous 写入 runtimeState) +const refreshAnonymousToken = async () => { + const registerAnonimous = require('../module/register_anonimous') + await registerAnonimous({}, refreshTokenRequest) +} + +// 强制刷新 xeapi 公钥(register_xeapikey 直接走 axios,无重入问题; +// 成功后由其写入 runtimeState) +const refreshXeapiPublicKey = async () => { + const registerXeapikey = require('../module/register_xeapikey') + await registerXeapikey( + { + currentKeyVersion: (runtimeState.xeapiPublicKey || {}).version || '', + }, + null, + ) +} + const createRequest = async (uri, data, options) => { let token = '' switch (options.checkToken) { @@ -188,37 +215,27 @@ const createRequest = async (uri, data, options) => { break } - // 懒加载匿名 token:仅当内存中缺失时刷新一次,失败不阻塞本次请求。 - // 兼容 serverless 冷启动——不假设启动阶段已刷新过。 - // 刷新类请求自身跳过(否则会等待自己造成死锁)。 - if ( - !runtimeState.getAnonymousToken() && - !runtimeState.isAnonymousTokenRefreshing() - ) { + // 匿名令牌缺失时按需引导:并发请求共享同一次刷新任务; + // 刷新自身的请求(skipTokenEnsure)跳过,避免任务等待自己造成死锁 + if (!runtimeState.anonymousToken && !options.skipTokenEnsure) { try { - const { ensureAnonymousToken } = require('./credentials') - await ensureAnonymousToken() + await runtimeState.ensure('anonymousToken', refreshAnonymousToken) } catch (error) { console.log('[ERR]', error) } } - // 懒加载 xeapi public key:仅在本次请求确实使用 xeapi 加密、且内存缺失时刷新。 - // 必须在进入同步 executor 前完成,因为加密过程需要同步拿到本状态。 - const resolvedCrypto = - options.crypto === '' || options.crypto === undefined + // 本请求走 xeapi 加密且公钥缺失时按需获取 + // (须在进入下方同步 executor 前完成,加密过程需要同步拿到公钥) + const pendingCrypto = + options.crypto === undefined || options.crypto === '' ? APP_CONF.encrypt ? 'eapi' : 'api' : options.crypto - if ( - resolvedCrypto === 'xeapi' && - !runtimeState.getXeapiPublicKey() && - !runtimeState.isXeapiPublicKeyRefreshing() - ) { + if (pendingCrypto === 'xeapi' && !runtimeState.xeapiPublicKey) { try { - const { ensureXeapiPublicKey } = require('./credentials') - await ensureXeapiPublicKey() + await runtimeState.ensure('xeapiPublicKey', refreshXeapiPublicKey) } catch (error) { console.log('[ERR]', error) } @@ -288,7 +305,7 @@ const createRequest = async (uri, data, options) => { break case 'xeapi': - const xeapiPublicKey = runtimeState.getXeapiPublicKey() + const xeapiPublicKey = runtimeState.xeapiPublicKey if (!xeapiPublicKey) { throw new Error('xeapi public key is missing') } @@ -531,3 +548,6 @@ const createRequest = async (uri, data, options) => { } module.exports = createRequest +// 供 generateConfig 等复用的强制刷新入口(成功后写入 runtimeState) +module.exports.refreshAnonymousToken = refreshAnonymousToken +module.exports.refreshXeapiPublicKey = refreshXeapiPublicKey diff --git a/util/runtimeState.js b/util/runtimeState.js index 2097c04..71fa398 100644 --- a/util/runtimeState.js +++ b/util/runtimeState.js @@ -1,261 +1,56 @@ -/** - * 统一运行时状态容器。 - * - * 历史上 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 冷启动(不假设启动时已刷新过)。 - */ +// 统一的运行时动态状态存储(仅内存,不落盘) +// +// 存放需要在进程内共享、且会在运行期间变化的动态配置, +// 目前包括匿名令牌(MUSIC_A)与 xeapi 公钥。 +// +// 设计约定: +// - 只存在于内存中,不做任何文件 I/O —— 在没有 /tmp 或临时目录 +// 不可写的平台(Android / iOS / 嵌入式 / serverless)上同样可用 +// - 写入方(module/register_anonimous.js、module/register_xeapikey.js) +// 在成功获取新值后立即写入,读取方(util/request.js)每次请求实时读取, +// 进程内即时生效,无需重启 +// - 凭证缺失时可通过 ensure() 按需刷新,并发调用共享同一次刷新任务 +// - 以后新增的动态配置请放在这里,不要再往 os.tmpdir() 写文件 -const fs = require('fs') -const path = require('path') -const os = require('os') +const runtimeState = { + /** 匿名令牌(MUSIC_A cookie),由 module/register_anonimous.js 成功后写入 */ + anonymousToken: '', -/** @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 - } + /** + * xeapi 公钥(含 sk / version / deviceId 等字段), + * 由 module/register_xeapikey.js 成功后写入 + */ + xeapiPublicKey: 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() : '' -} +/** 进行中的按需刷新任务:并发调用共享同一次刷新,避免重复请求 */ +const refreshTasks = new Map() /** - * 读取 xeapi_public_key 的可选种子(环境变量优先,其次旧 /tmp 文件)。 - * @returns {Record | null} + * 确保某项动态状态可用:已有值时直接返回;缺失时执行 refresh 刷新 + * (refresh 成功后需自行把新值写入 runtimeState[key])。 + * 并发调用会共享同一个进行中的刷新任务。 + * + * @param {string} key runtimeState 上的字段名 + * @param {() => Promise} refresh 刷新函数 + * @returns {Promise<*>} 该状态当前的值(刷新失败时抛出错误) */ -const readXeapiPublicKeySeed = () => { - const fromEnv = process.env.NETEASE_XEAPI_PUBLIC_KEY - const raw = - fromEnv || - safeReadFile( - legacyTmpPath ? path.resolve(legacyTmpPath, 'xeapi_public_key') : '', +runtimeState.ensure = async (key, refresh) => { + if (runtimeState[key]) return runtimeState[key] + + if (!refreshTasks.has(key)) { + refreshTasks.set( + key, + Promise.resolve() + .then(refresh) + .finally(() => { + refreshTasks.delete(key) + }), ) - if (!raw) return null - try { - return JSON.parse(raw) - } catch (_) { - return null } + + await refreshTasks.get(key) + return runtimeState[key] } -/** - * 获取当前 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, -} +module.exports = runtimeState diff --git a/util/runtimeState.test.js b/util/runtimeState.test.js deleted file mode 100644 index e1a8284..0000000 --- a/util/runtimeState.test.js +++ /dev/null @@ -1,235 +0,0 @@ -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) - }) - }) -}) diff --git a/util/xeapiKey.js b/util/xeapiKey.js deleted file mode 100644 index d308df5..0000000 --- a/util/xeapiKey.js +++ /dev/null @@ -1,24 +0,0 @@ -const registerXeapiKey = require('../module/register_xeapikey') - -const getXeapiPublicKey = async (currentPublicKey = {}, deviceId = '') => { - const result = await registerXeapiKey( - { - deviceId, - currentKeyVersion: currentPublicKey.version || '', - }, - null, - ) - - const publicKey = result.body - if (!publicKey.sk && currentPublicKey.sk) { - publicKey.sk = currentPublicKey.sk - } - if (!publicKey.sk) { - throw new Error('xeapi public key response missing sk') - } - return publicKey -} - -module.exports = { - getXeapiPublicKey, -}