From 7985690987e01c8d67a9404e19fcb32f455d760f Mon Sep 17 00:00:00 2001 From: pan93412 Date: Wed, 26 Jan 2022 11:29:07 +0800 Subject: [PATCH] refactor(server): separate server from app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 我們需要 NCM API 的伺服器部分,但 app.js 目前的做法讓我們不太好單獨啟動伺服器。 我將 app.js 的伺服器部分抽成各個函數,並將原有 會 blocking 的函數更改為非同步函式。 這樣不僅能最大化善用 Node.js 的 Event Loop, 亦能提升未來維護的程式碼易讀性。 --- app.js | 146 +------------------------ package-lock.json | 8 +- server.js | 263 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+), 146 deletions(-) create mode 100644 server.js diff --git a/app.js b/app.js index 26e657a..3e5e976 100644 --- a/app.js +++ b/app.js @@ -1,146 +1,4 @@ #!/usr/bin/env node -const fs = require('fs') -const path = require('path') -const express = require('express') -const bodyParser = require('body-parser') -const request = require('./util/request') -const packageJSON = require('./package.json') -const exec = require('child_process').exec -const cache = require('./util/apicache').middleware -const { cookieToJson } = require('./util/index') -const fileUpload = require('express-fileupload') -const decode = require('safe-decode-uri-component') - -// version check -exec('npm info NeteaseCloudMusicApi version', (err, stdout, stderr) => { - if (!err) { - let version = stdout.trim() - if (packageJSON.version < version) { - console.log( - `最新版本: ${version}, 当前版本: ${packageJSON.version}, 请及时更新`, - ) - } - } +require('./server')({ + checkVersion: true, }) - -const app = express() -app.set('trust proxy', true) - -// CORS & Preflight request -app.use((req, res, next) => { - if (req.path !== '/' && !req.path.includes('.')) { - res.set({ - 'Access-Control-Allow-Credentials': true, - 'Access-Control-Allow-Origin': req.headers.origin || '*', - 'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type', - 'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS', - 'Content-Type': 'application/json; charset=utf-8', - }) - } - req.method === 'OPTIONS' ? res.status(204).end() : next() -}) - -// cookie parser -app.use((req, res, next) => { - req.cookies = {} - //;(req.headers.cookie || '').split(/\s*;\s*/).forEach((pair) => { // Polynomial regular expression // - ;(req.headers.cookie || '').split(/;\s+|(? { - let crack = pair.indexOf('=') - if (crack < 1 || crack == pair.length - 1) return - req.cookies[decode(pair.slice(0, crack)).trim()] = decode( - pair.slice(crack + 1), - ).trim() - }) - next() -}) - -// body parser -app.use(bodyParser.json()) -app.use(bodyParser.urlencoded({ extended: false })) - -app.use(fileUpload()) - -// static -app.use(express.static(path.join(__dirname, 'public'))) - -// cache -app.use(cache('2 minutes', (req, res) => res.statusCode === 200)) -// router -const special = { - 'daily_signin.js': '/daily_signin', - 'fm_trash.js': '/fm_trash', - 'personal_fm.js': '/personal_fm', -} - -fs.readdirSync(path.join(__dirname, 'module')) - .reverse() - .forEach((file) => { - if (!file.endsWith('.js')) return - let route = - file in special - ? special[file] - : '/' + file.replace(/\.js$/i, '').replace(/_/g, '/') - let question = require(path.join(__dirname, 'module', file)) - - app.use(route, (req, res) => { - ;[req.query, req.body].forEach((item) => { - if (typeof item.cookie === 'string') { - item.cookie = cookieToJson(decode(item.cookie)) - } - }) - let query = Object.assign( - {}, - { cookie: req.cookies }, - req.query, - req.body, - req.files, - ) - - question(query, request) - .then((answer) => { - console.log('[OK]', decode(req.originalUrl)) - - const cookies = answer.cookie - if (Array.isArray(cookies) && cookies.length > 0) { - if (req.protocol === 'https') { - // Try to fix CORS SameSite Problem - res.append( - 'Set-Cookie', - cookies.map((cookie) => { - return cookie + '; SameSite=None; Secure' - }), - ) - } else { - res.append('Set-Cookie', cookies) - } - } - res.status(answer.status).send(answer.body) - }) - .catch((answer) => { - console.log('[ERR]', decode(req.originalUrl), { - status: answer.status, - body: answer.body, - }) - if (!answer.body) { - res.status(404).send({ - code: 404, - data: null, - msg: 'Not Found', - }) - return - } - if (answer.body.code == '301') answer.body.msg = '需要登录' - res.append('Set-Cookie', answer.cookie) - res.status(answer.status).send(answer.body) - }) - }) - }) - -const port = process.env.PORT || 3000 -const host = process.env.HOST || '' - -app.server = app.listen(port, host, () => { - console.log(`server running @ http://${host ? host : 'localhost'}:${port}`) -}) - -module.exports = app diff --git a/package-lock.json b/package-lock.json index 09bb2b6..227a6ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "NeteaseCloudMusicApi", - "version": "4.2.0", + "version": "4.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "NeteaseCloudMusicApi", - "version": "4.2.0", + "version": "4.3.0", "license": "MIT", "dependencies": { "axios": "^0.24.0", @@ -1502,6 +1502,8 @@ "resolved": "https://registry.npm.taobao.org/enquirer/download/enquirer-2.3.6.tgz?cache=0&sync_timestamp=1593693291943&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fenquirer%2Fdownload%2Fenquirer-2.3.6.tgz", "integrity": "sha1-Kn/l3WNKHkElqXXsmU/1RW3Dc00=", "dev": true, + "optional": true, + "peer": true, "dependencies": { "ansi-colors": "^4.1.1" }, @@ -6743,6 +6745,8 @@ "resolved": "https://registry.npm.taobao.org/enquirer/download/enquirer-2.3.6.tgz?cache=0&sync_timestamp=1593693291943&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fenquirer%2Fdownload%2Fenquirer-2.3.6.tgz", "integrity": "sha1-Kn/l3WNKHkElqXXsmU/1RW3Dc00=", "dev": true, + "optional": true, + "peer": true, "requires": { "ansi-colors": "^4.1.1" } diff --git a/server.js b/server.js new file mode 100644 index 0000000..4c52653 --- /dev/null +++ b/server.js @@ -0,0 +1,263 @@ +#!/usr/bin/env node +const fs = require('fs') +const path = require('path') +const express = require('express') +const bodyParser = require('body-parser') +const request = require('./util/request') +const packageJSON = require('./package.json') +const exec = require('child_process').exec +const cache = require('./util/apicache').middleware +const { cookieToJson } = require('./util/index') +const fileUpload = require('express-fileupload') +const decode = require('safe-decode-uri-component') + +/** + * The version check result. + * @readonly + * @enum {number} + */ +const VERSION_CHECK_RESULT = { + FAILED: -1, + NOT_LATEST: 0, + LATEST: 1, +} + +/** + * @typedef {{ + * port?: number, + * host?: string, + * checkVersion?: boolean, + * }} NcmApiOptions + */ + +/** + * @typedef {{ + * status: VERSION_CHECK_RESULT, + * ourVersion?: string, + * npmVersion?: string, + * }} VersionCheckResult + */ + +/** + * @typedef {{ + * server?: import('http').Server, + * }} ExpressExtension + */ + +/** + * Check if the version of this API is latest. + * + * @returns {Promise} If true, this API is up-to-date; + * otherwise, this API should be upgraded and you would + * need to notify users to upgrade it manually. + */ +async function checkVersion() { + return new Promise((resolve, reject) => { + exec('npm info NeteaseCloudMusicApi version', (err, stdout, stderr) => { + if (!err) { + let version = stdout.trim() + + /** + * @param {VERSION_CHECK_RESULT} status + */ + const resolveStatus = (status) => + resolve({ + status, + ourVersion: packageJSON.version, + npmVersion: version, + }) + + resolveStatus( + packageJSON.version < version + ? VERSION_CHECK_RESULT.NOT_LATEST + : VERSION_CHECK_RESULT.LATEST, + ) + } + }) + + resolve({ + status: VERSION_CHECK_RESULT.FAILED, + }) + }) +} + +/** + * Construct the server of NCM API. + * + * @returns {Promise} The server instance. + */ +async function consturctServer() { + const app = express() + app.set('trust proxy', true) + + /** + * CORS & Preflight request + */ + app.use((req, res, next) => { + if (req.path !== '/' && !req.path.includes('.')) { + res.set({ + 'Access-Control-Allow-Credentials': true, + 'Access-Control-Allow-Origin': req.headers.origin || '*', + 'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type', + 'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS', + 'Content-Type': 'application/json; charset=utf-8', + }) + } + req.method === 'OPTIONS' ? res.status(204).end() : next() + }) + + /** + * Cookie Parser + */ + app.use((req, res, next) => { + req.cookies = {} + //;(req.headers.cookie || '').split(/\s*;\s*/).forEach((pair) => { // Polynomial regular expression // + ;(req.headers.cookie || '').split(/;\s+|(? { + let crack = pair.indexOf('=') + if (crack < 1 || crack == pair.length - 1) return + req.cookies[decode(pair.slice(0, crack)).trim()] = decode( + pair.slice(crack + 1), + ).trim() + }) + next() + }) + + /** + * Body Parser and File Upload + */ + app.use(bodyParser.json()) + app.use(bodyParser.urlencoded({ extended: false })) + + app.use(fileUpload()) + + /** + * Serving static files + */ + app.use(express.static(path.join(__dirname, 'public'))) + + /** + * Cache + */ + app.use(cache('2 minutes', (_, res) => res.statusCode === 200)) + + /** + * Special Routers + */ + const special = { + 'daily_signin.js': '/daily_signin', + 'fm_trash.js': '/fm_trash', + 'personal_fm.js': '/personal_fm', + } + + /** + * Load every modules in this directory + */ + const modules = ( + await fs.promises.readdir(path.join(__dirname, 'module')) + ).reverse() + for (const file of modules) { + // Check if the file is written in JS. + if (!file.endsWith('.js')) continue + + // Get the route path. + const route = + file in special + ? special[file] + : '/' + file.replace(/\.js$/i, '').replace(/_/g, '/') + + // Get the module itself. + const module = require(path.join(__dirname, 'module', file)) + + // Register the route. + app.use(route, async (req, res) => { + ;[req.query, req.body].forEach((item) => { + if (typeof item.cookie === 'string') { + item.cookie = cookieToJson(decode(item.cookie)) + } + }) + + let query = Object.assign( + {}, + { cookie: req.cookies }, + req.query, + req.body, + ) + + try { + const moduleResponse = await module(query, request) + console.log('[OK]', decode(req.originalUrl)) + + const cookies = moduleResponse.cookie + if (Array.isArray(cookies) && cookies.length > 0) { + if (req.protocol === 'https') { + // Try to fix CORS SameSite Problem + res.append( + 'Set-Cookie', + cookies.map((cookie) => { + return cookie + '; SameSite=None; Secure' + }), + ) + } else { + res.append('Set-Cookie', cookies) + } + } + res.status(moduleResponse.status).send(moduleResponse.body) + } catch (/** @type {*} */ moduleResponse) { + console.log('[ERR]', decode(req.originalUrl), { + status: moduleResponse.status, + body: moduleResponse.body, + }) + if (!moduleResponse.body) { + res.status(404).send({ + code: 404, + data: null, + msg: 'Not Found', + }) + return + } + if (moduleResponse.body.code == '301') + moduleResponse.body.msg = '需要登录' + res.append('Set-Cookie', moduleResponse.cookie) + res.status(moduleResponse.status).send(moduleResponse.body) + } + }) + } + + return app +} + +/** + * Serve the NCM API. + * @param {NcmApiOptions} options + * @returns {Promise} + */ +async function serveNcmApi(options) { + const port = Number(options.port || process.env.PORT || '3000') + const host = options.host || process.env.HOST || '' + + const checkVersionSubmission = + options.checkVersion && + checkVersion().then(({ npmVersion, ourVersion, status }) => { + if (status == VERSION_CHECK_RESULT.NOT_LATEST) { + console.log( + `最新版本: ${npmVersion}, 当前版本: ${ourVersion}, 请及时更新`, + ) + } + }) + const constructServerSubmission = consturctServer() + + const [_, app] = await Promise.all([ + checkVersionSubmission, + constructServerSubmission, + ]) + + /** @type {import('express').Express & ExpressExtension} */ + const appExt = app + appExt.server = app.listen(port, host, () => { + console.log(`server running @ http://${host ? host : 'localhost'}:${port}`) + }) + + return appExt +} + +module.exports = serveNcmApi