mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Merge pull request #155 from ZHANGTIANYAO1/fix/152-setup-console-eio
fix(setup): stop a failed console write from aborting setup, require Node 22+ (#152)
This commit is contained in:
9 files changed
+349
-30
No files matched your search
@@ -65,22 +65,22 @@
|
|||||||
先装好 Node.js,其余依赖(含内置 FFmpeg)全部自动安装。
|
先装好 Node.js,其余依赖(含内置 FFmpeg)全部自动安装。
|
||||||
|
|
||||||
```
|
```
|
||||||
1. 安装 Node.js 20 LTS 或 22 LTS(https://nodejs.org/ 或 https://nodejs.cn/)
|
1. 安装 Node.js 22 LTS(https://nodejs.org/ 或 https://nodejs.cn/)
|
||||||
2. 下载或 clone 本项目
|
2. 下载或 clone 本项目
|
||||||
3. 双击 scripts\setup.bat (安装依赖并构建,不含 Node.js 本身)
|
3. 双击 scripts\setup.bat (安装依赖并构建,不含 Node.js 本身)
|
||||||
4. 双击 scripts\start.bat (启动机器人)
|
4. 双击 scripts\start.bat (启动机器人)
|
||||||
5. 浏览器打开 http://localhost:3000
|
5. 浏览器打开 http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
> **先装 Node.js 20 LTS 或 22 LTS**([nodejs.org](https://nodejs.org/) / 国内镜像 [nodejs.cn](https://nodejs.cn/))。`setup.bat` 检测到没装 Node 时会给出下载地址并退出,不会替你安装。
|
> **先装 Node.js 22 LTS**([nodejs.org](https://nodejs.org/) / 国内镜像 [nodejs.cn](https://nodejs.cn/))。`setup.bat` 检测到没装 Node 时会给出下载地址并退出,不会替你安装。
|
||||||
>
|
>
|
||||||
> 之后 `setup.bat` 会运行 `npm install` 安装所有依赖(包括内置 FFmpeg),按当前 Node 版本准备好原生模块,最后构建项目。之后每次只需双击 `start.bat` 启动。
|
> 之后 `setup.bat` 会运行 `npm install` 安装所有依赖(包括内置 FFmpeg),按当前 Node 版本准备好原生模块,最后构建项目。之后每次只需双击 `start.bat` 启动。
|
||||||
>
|
>
|
||||||
> 更新的 Node 大版本(如 24)也能用,但通常没有现成的 opus / better-sqlite3 预编译包,安装脚本会改用源码编译,需要 C/C++ 构建工具且耗时更久——所以推荐 20 / 22 LTS。**装好之后不要再换 Node 大版本**:原生模块只能在编译它的那个版本上加载,换版本后必须重新运行 `setup.bat`(脚本会自动检测并重装,见下方常见问题)。
|
> **Node 20 已不再支持**:better-sqlite3 从 12.10.0 起不再发布它那个 ABI(115)的预编译包,装起来必须先备好 Python + C++ 构建工具([#152](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/152))。Node 24 及更新的大版本能用,但 @discordjs/opus 0.10.0 同样没有 Node 24(ABI 137)的预编译包,安装脚本会改用源码编译,需要构建工具且耗时更久——所以推荐 22 LTS。**装好之后不要再换 Node 大版本**:原生模块只能在编译它的那个版本上加载,换版本后必须重新运行 `setup.bat`(脚本会自动检测并重装,见下方常见问题)。
|
||||||
|
|
||||||
### 方式二:手动安装(所有系统)
|
### 方式二:手动安装(所有系统)
|
||||||
|
|
||||||
**前置条件:** [Node.js 20 LTS 或 22 LTS](https://nodejs.org/)(推荐;更新的大版本可用但需要源码编译原生模块)和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
|
**前置条件:** [Node.js 22 LTS](https://nodejs.org/)(Node 24 及更新版本也能用,但需要源码编译原生模块;Node 20 已不再支持)和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
|
||||||
FFmpeg **已自动内置**,无需手动安装。
|
FFmpeg **已自动内置**,无需手动安装。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -510,7 +510,7 @@ teamspeak-music-bot/
|
|||||||
|
|
||||||
| 层级 | 技术 |
|
| 层级 | 技术 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **运行时** | Node.js 20 / 22 LTS, TypeScript 5 |
|
| **运行时** | Node.js 22 LTS(推荐), TypeScript 5 |
|
||||||
| **后端框架** | Express 4, WebSocket (ws) |
|
| **后端框架** | Express 4, WebSocket (ws) |
|
||||||
| **数据库** | better-sqlite3 (SQLite) |
|
| **数据库** | better-sqlite3 (SQLite) |
|
||||||
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
|
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
"description": "TeamSpeak music bot with NetEase Cloud Music and QQ Music support",
|
"description": "TeamSpeak music bot with NetEase Cloud Music and QQ Music support",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^22.12.0 || >=24.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { execFileSync } from "node:child_process";
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createLineWriter } from "./lib/console-log.mjs";
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const NODE_MODULES = join(ROOT, "node_modules");
|
const NODE_MODULES = join(ROOT, "node_modules");
|
||||||
@@ -106,7 +107,11 @@ for (const spec of REQUIRED) {
|
|||||||
function report() {
|
function report() {
|
||||||
const stamp = readStamp();
|
const stamp = readStamp();
|
||||||
const mismatch = broken.find((b) => b.abiMismatch);
|
const mismatch = broken.find((b) => b.abiMismatch);
|
||||||
const out = (line) => process.stderr.write(`${line}\n`);
|
// Never let a failed console write become an uncaught error and replace this
|
||||||
|
// report with a stack trace - the console that cannot print the Chinese half
|
||||||
|
// of these lines is exactly the one a user needs the English half from.
|
||||||
|
// See scripts/lib/console-log.mjs and issue #152.
|
||||||
|
const out = createLineWriter(process.stderr);
|
||||||
|
|
||||||
out("");
|
out("");
|
||||||
out("============================================================");
|
out("============================================================");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
# --- Stage 1: Build backend + frontend ---
|
# --- Stage 1: Build backend + frontend ---
|
||||||
FROM node:20-slim AS builder
|
FROM node:22-slim AS builder
|
||||||
|
|
||||||
# Install build tools for native modules (opus, better-sqlite3)
|
# Install build tools for native modules (opus, better-sqlite3)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -30,7 +30,7 @@ RUN npm run build
|
|||||||
RUN rm -rf node_modules && npm ci --production && npm cache clean --force
|
RUN rm -rf node_modules && npm ci --production && npm cache clean --force
|
||||||
|
|
||||||
# --- Stage 2: Production image ---
|
# --- Stage 2: Production image ---
|
||||||
FROM node:20-slim
|
FROM node:22-slim
|
||||||
|
|
||||||
# Install system FFmpeg — the ffmpeg-static npm package bundles a pre-compiled
|
# Install system FFmpeg — the ffmpeg-static npm package bundles a pre-compiled
|
||||||
# binary that can SIGSEGV inside Docker (incompatible glibc / missing libs).
|
# binary that can SIGSEGV inside Docker (incompatible glibc / missing libs).
|
||||||
@@ -51,6 +51,8 @@ COPY --from=builder /app/node_modules ./node_modules
|
|||||||
# goes through npm, but an interactive `docker exec ... npm start` would
|
# goes through npm, but an interactive `docker exec ... npm start` would
|
||||||
# otherwise die on a missing script rather than starting the bot.
|
# otherwise die on a missing script rather than starting the bot.
|
||||||
COPY --from=builder /app/scripts/check-native.mjs ./scripts/check-native.mjs
|
COPY --from=builder /app/scripts/check-native.mjs ./scripts/check-native.mjs
|
||||||
|
# ...and the module it imports for crash-proof logging.
|
||||||
|
COPY --from=builder /app/scripts/lib/console-log.mjs ./scripts/lib/console-log.mjs
|
||||||
|
|
||||||
# Data directory for database, cookies, logs
|
# Data directory for database, cookies, logs
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { Readable } from "node:stream";
|
|||||||
import { execFileSync, execSync } from "node:child_process";
|
import { execFileSync, execSync } from "node:child_process";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createLineWriter } from "./lib/console-log.mjs";
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const NODE_MODULES = join(ROOT, "node_modules");
|
const NODE_MODULES = join(ROOT, "node_modules");
|
||||||
@@ -61,6 +62,11 @@ const CDN = process.argv[2] || "https://cdn.npmmirror.com/binaries";
|
|||||||
const PLATFORM = process.platform;
|
const PLATFORM = process.platform;
|
||||||
const ARCH = process.arch;
|
const ARCH = process.arch;
|
||||||
const NODE_ABI = process.versions.modules;
|
const NODE_ABI = process.versions.modules;
|
||||||
|
const NODE_MAJOR = Number(process.versions.node.split(".")[0]);
|
||||||
|
/** The newest Node major this project is regularly tested against, and the one
|
||||||
|
* every required addon currently ships a prebuild for. Keep in sync with
|
||||||
|
* TESTED_NODE_MAJOR in scripts/setup.bat. */
|
||||||
|
const TESTED_NODE_MAJOR = 22;
|
||||||
|
|
||||||
/** Modules the bot cannot start without. ffmpeg-static is optional: a system
|
/** Modules the bot cannot start without. ffmpeg-static is optional: a system
|
||||||
* ffmpeg on PATH is a documented fallback, so it only ever produces a WARN. */
|
* ffmpeg on PATH is a documented fallback, so it only ever produces a WARN. */
|
||||||
@@ -79,10 +85,17 @@ const FFMPEG_MIN_BYTES = 20 * 1024 * 1024;
|
|||||||
// log keeps the full transcript too.
|
// log keeps the full transcript too.
|
||||||
const ECHO_STDOUT = process.env.TSMB_BINARY_LOG_STDOUT === "1";
|
const ECHO_STDOUT = process.env.TSMB_BINARY_LOG_STDOUT === "1";
|
||||||
|
|
||||||
|
// Both writers swallow a failed write instead of letting it become an uncaught
|
||||||
|
// 'error' event: a console that cannot print the Chinese half of a line (issue
|
||||||
|
// #152) must not be able to abort a whole setup run. The two streams degrade
|
||||||
|
// independently, so setup.log keeps the full bilingual transcript either way.
|
||||||
|
const writeErr = createLineWriter(process.stderr);
|
||||||
|
const writeOut = createLineWriter(process.stdout);
|
||||||
|
|
||||||
function log(msg) {
|
function log(msg) {
|
||||||
const line = msg === "" ? "" : ` [binary] ${msg}`;
|
const line = msg === "" ? "" : ` [binary] ${msg}`;
|
||||||
process.stderr.write(`${line}\n`);
|
writeErr(line);
|
||||||
if (ECHO_STDOUT) process.stdout.write(`${line}\n`);
|
if (ECHO_STDOUT) writeOut(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -401,6 +414,25 @@ function buildFromSource(command) {
|
|||||||
execSync(command, { cwd: ROOT, stdio: ["ignore", "inherit", "inherit"] });
|
execSync(command, { cwd: ROOT, stdio: ["ignore", "inherit", "inherit"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 404 from the CDN is not a mirror outage: it means this exact package
|
||||||
|
* version publishes no prebuilt binary for the running Node ABI at all.
|
||||||
|
* @discordjs/opus 0.10.0 has no build for Node 24 (ABI 137), so a user on that
|
||||||
|
* major lands in the source-build fallback below and is told to install Python
|
||||||
|
* and a C++ toolchain. Switching Node major is the far cheaper fix, and nothing
|
||||||
|
* else in this output points at it. (better-sqlite3 dropping its Node 20 / ABI
|
||||||
|
* 115 builds in 12.10.0 is why Node 20 is no longer accepted at all.)
|
||||||
|
* See issue #152.
|
||||||
|
*/
|
||||||
|
function explainMissingPrebuild(name, version, err) {
|
||||||
|
if (!/HTTP 404/.test(err.message)) return;
|
||||||
|
log(`${name}: ${name}@${version} ships no prebuilt binary for Node ${NODE_MAJOR} (ABI ${NODE_ABI})`);
|
||||||
|
log(
|
||||||
|
`${name}: Node ${TESTED_NODE_MAJOR} LTS has one — switching Node is usually much quicker than ` +
|
||||||
|
`setting up a compiler (换用 Node ${TESTED_NODE_MAJOR} LTS 通常比装编译环境快得多)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildToolsHint() {
|
function buildToolsHint() {
|
||||||
log("Install build tools first:");
|
log("Install build tools first:");
|
||||||
log(" Windows: npm install --global windows-build-tools (或安装 Visual Studio Build Tools + Python)");
|
log(" Windows: npm install --global windows-build-tools (或安装 Visual Studio Build Tools + Python)");
|
||||||
@@ -521,6 +553,7 @@ async function ensureOpus() {
|
|||||||
log(`${name}: prebuilt binary installed`);
|
log(`${name}: prebuilt binary installed`);
|
||||||
} catch (cdnErr) {
|
} catch (cdnErr) {
|
||||||
log(`${name}: CDN install failed (${cdnErr.message})`);
|
log(`${name}: CDN install failed (${cdnErr.message})`);
|
||||||
|
explainMissingPrebuild(name, version, cdnErr);
|
||||||
log(`${name}: falling back to a source build — 'npm rebuild ${name}' (可能需要几分钟)`);
|
log(`${name}: falling back to a source build — 'npm rebuild ${name}' (可能需要几分钟)`);
|
||||||
buildFromSource(`npm rebuild ${name}`);
|
buildFromSource(`npm rebuild ${name}`);
|
||||||
}
|
}
|
||||||
@@ -591,6 +624,7 @@ async function ensureBetterSqlite3() {
|
|||||||
log(`${name}: prebuilt binary installed (${humanSize(dest)})`);
|
log(`${name}: prebuilt binary installed (${humanSize(dest)})`);
|
||||||
} catch (cdnErr) {
|
} catch (cdnErr) {
|
||||||
log(`${name}: CDN install failed (${cdnErr.message})`);
|
log(`${name}: CDN install failed (${cdnErr.message})`);
|
||||||
|
explainMissingPrebuild(name, version, cdnErr);
|
||||||
log(`${name}: falling back to a source build — 'npm rebuild ${name} --build-from-source' (可能需要几分钟)`);
|
log(`${name}: falling back to a source build — 'npm rebuild ${name} --build-from-source' (可能需要几分钟)`);
|
||||||
buildFromSource(`npm rebuild ${name} --build-from-source`);
|
buildFromSource(`npm rebuild ${name} --build-from-source`);
|
||||||
}
|
}
|
||||||
@@ -661,10 +695,9 @@ try {
|
|||||||
// that same loop. Run these concurrently and the first module to fall back to
|
// that same loop. Run these concurrently and the first module to fall back to
|
||||||
// a source build kills every download still in flight — the connection is
|
// a source build kills every download still in flight — the connection is
|
||||||
// healthy, the timer just never got a chance to be reset. That is not a rare
|
// healthy, the timer just never got a chance to be reset. That is not a rare
|
||||||
// race: npmmirror has no opus prebuild for ABI 137 (Node 24) and no
|
// race: @discordjs/opus 0.10.0 has no prebuild for ABI 137, so on Node 24 that
|
||||||
// better-sqlite3 prebuild for ABI 115 (Node 20), so on both of the Node
|
// module 404s within ~100ms and starts building while ffmpeg's ~80MB download
|
||||||
// versions this project supports, one module 404s within ~100ms and starts
|
// is still going. ffmpeg is optional,
|
||||||
// building while ffmpeg's ~80MB download is still going. ffmpeg is optional,
|
|
||||||
// so the spurious failure used to be swallowed as a WARN and setup still
|
// so the spurious failure used to be swallowed as a WARN and setup still
|
||||||
// reported success — leaving the user with no ffmpeg and no working playback.
|
// reported success — leaving the user with no ffmpeg and no working playback.
|
||||||
// Nothing here benefits from overlap anyway: every probe is execFileSync.
|
// Nothing here benefits from overlap anyway: every probe is execFileSync.
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Crash-proof line logging for the setup scripts.
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS (issue #152)
|
||||||
|
* ----------------------------
|
||||||
|
* setup.bat runs `chcp 65001` and shows progress on stderr. Some Windows
|
||||||
|
* consoles - Windows Server 2012 R2 above all - cannot render non-ASCII text in
|
||||||
|
* that code page and the OS fails the write with EIO. `process.stderr` is an
|
||||||
|
* ordinary stream, so that EIO arrives as an 'error' event, and a stream with
|
||||||
|
* no 'error' listener rethrows it as an uncaught exception:
|
||||||
|
*
|
||||||
|
* Error: write EIO
|
||||||
|
* at afterWriteDispatched (node:internal/stream_base_commons:159:15)
|
||||||
|
* ...
|
||||||
|
* at log (scripts/download-binaries.mjs:84:18)
|
||||||
|
* at ensureFfmpeg (scripts/download-binaries.mjs:451:5)
|
||||||
|
*
|
||||||
|
* That is setup killing itself inside its own progress logging, on the first
|
||||||
|
* line of the run that happened to contain Chinese - nothing was wrong with the
|
||||||
|
* download it was about to start.
|
||||||
|
*
|
||||||
|
* So: listen for the error and degrade instead of dying.
|
||||||
|
* full -> ascii : drop the CJK the console choked on, keep the English half
|
||||||
|
* ascii -> off : the stream is simply gone (closed pipe) - stay quiet
|
||||||
|
* Each stream degrades on its own, so a console that gives up does not cost
|
||||||
|
* setup.log its full bilingual transcript: that stdout is a redirected file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HAS_NON_ASCII = /[^\x00-\x7F]/;
|
||||||
|
|
||||||
|
/** Placeholders for a removed run: one that separated words, one that did not. */
|
||||||
|
const SPACED = "\u0000";
|
||||||
|
const TIGHT = "\u0001";
|
||||||
|
|
||||||
|
/** Punctuation the bilingual strings use that has an obvious ASCII twin. */
|
||||||
|
const PUNCTUATION = new Map(
|
||||||
|
Object.entries({
|
||||||
|
"—": "-",
|
||||||
|
"–": "-",
|
||||||
|
"…": "...",
|
||||||
|
"“": '"',
|
||||||
|
"”": '"',
|
||||||
|
"‘": "'",
|
||||||
|
"’": "'",
|
||||||
|
",": ",",
|
||||||
|
"。": ".",
|
||||||
|
"、": ",",
|
||||||
|
":": ":",
|
||||||
|
";": ";",
|
||||||
|
"(": "(",
|
||||||
|
")": ")",
|
||||||
|
"!": "!",
|
||||||
|
"?": "?",
|
||||||
|
"←": "<-",
|
||||||
|
"→": "->",
|
||||||
|
"×": "x",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort ASCII rendering of a log line, for a console that cannot print
|
||||||
|
* anything else. Returns null when nothing worth printing survives - every
|
||||||
|
* Chinese-only line in these scripts sits directly beside an English line
|
||||||
|
* saying the same thing, so dropping it loses no information.
|
||||||
|
*/
|
||||||
|
export function toAsciiFallback(text) {
|
||||||
|
if (!HAS_NON_ASCII.test(text)) return text;
|
||||||
|
|
||||||
|
let out = "";
|
||||||
|
for (const ch of text) out += PUNCTUATION.get(ch) ?? ch;
|
||||||
|
|
||||||
|
out = out
|
||||||
|
.replace(/[\u0000\u0001]/g, "")
|
||||||
|
// Mark each removed run rather than just deleting it, so the tidy-up below
|
||||||
|
// can tell "a separator that introduced text we dropped" from "a separator
|
||||||
|
// that belongs to the English half". SPACED was holding two ASCII words
|
||||||
|
// apart; TIGHT was hugging a bracket or a comma.
|
||||||
|
.replace(/[ \t]*[^\x00-\x7F]+[ \t]*/g, (run) =>
|
||||||
|
/^[ \t]/.test(run) && /[ \t]$/.test(run) ? SPACED : TIGHT,
|
||||||
|
)
|
||||||
|
// "(可能需要几分钟)" — the parentheses held nothing else.
|
||||||
|
.replace(/[ \t]*\([ \t]*(?:[\u0000\u0001][ \t]*)+\)/g, "")
|
||||||
|
// "FAILED — 编译失败", "(~80 MB, 请耐心等待)" — drop the trailing marks along
|
||||||
|
// with the separators that were only ever there to introduce them.
|
||||||
|
.replace(/[ \t]*[-,;:]*[ \t]*(?:[\u0000\u0001][ \t,;:-]*)+(?=[)\]]|$)/gm, "")
|
||||||
|
.replace(/\u0000/g, " ")
|
||||||
|
.replace(/\u0001/g, "")
|
||||||
|
.replace(/[ \t]+$/gm, "");
|
||||||
|
|
||||||
|
return /[A-Za-z0-9]/.test(out) ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One degradation state per stream, shared by every writer built on it. */
|
||||||
|
const guards = new WeakMap();
|
||||||
|
|
||||||
|
function guardFor(stream) {
|
||||||
|
const existing = guards.get(stream);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const guard = { mode: "full" };
|
||||||
|
guards.set(stream, guard);
|
||||||
|
try {
|
||||||
|
// The whole point: without this listener the next EIO/EPIPE is fatal.
|
||||||
|
stream.on("error", () => degrade(guard));
|
||||||
|
} catch {
|
||||||
|
/* not an EventEmitter - the try/catch around write() still guards us */
|
||||||
|
}
|
||||||
|
return guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
function degrade(guard) {
|
||||||
|
guard.mode = guard.mode === "full" ? "ascii" : "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a `writeLine(text)` that appends a newline, never throws, and never
|
||||||
|
* lets a failed console write take the process down with it.
|
||||||
|
* Returns true when the line reached the stream.
|
||||||
|
*/
|
||||||
|
export function createLineWriter(stream) {
|
||||||
|
const guard = guardFor(stream);
|
||||||
|
|
||||||
|
return function writeLine(text) {
|
||||||
|
if (guard.mode === "off") return false;
|
||||||
|
|
||||||
|
let line = text;
|
||||||
|
if (guard.mode === "ascii") {
|
||||||
|
line = toAsciiFallback(text);
|
||||||
|
if (line === null) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
stream.write(`${line}\n`);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// A synchronous throw (EBADF on a closed handle) never reaches the
|
||||||
|
// 'error' listener, so degrade here too.
|
||||||
|
degrade(guard);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createLineWriter, toAsciiFallback } from "./console-log.mjs";
|
||||||
|
|
||||||
|
/** Stand-in for process.stderr: an EventEmitter with a write() we can steer. */
|
||||||
|
function fakeStream() {
|
||||||
|
const stream = new EventEmitter();
|
||||||
|
stream.written = [];
|
||||||
|
stream.throwOnWrite = false;
|
||||||
|
stream.write = (chunk) => {
|
||||||
|
if (stream.throwOnWrite) throw new Error("EBADF");
|
||||||
|
stream.written.push(chunk);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("toAsciiFallback", () => {
|
||||||
|
it("leaves ASCII lines exactly as they are", () => {
|
||||||
|
const line = " [binary] better-sqlite3: OK (loads under v22.23.2, ABI 127)";
|
||||||
|
expect(toAsciiFallback(line)).toBe(line);
|
||||||
|
expect(toAsciiFallback("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the English half of the line that crashed setup in #152", () => {
|
||||||
|
expect(
|
||||||
|
toAsciiFallback(
|
||||||
|
" [binary] ffmpeg-static: GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)",
|
||||||
|
),
|
||||||
|
).toBe(" [binary] ffmpeg-static: GET https://cdn/ffmpeg.gz (~80 MB)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops parentheses and separators left stranded by the removed text", () => {
|
||||||
|
expect(
|
||||||
|
toAsciiFallback(" [binary] better-sqlite3: falling back — 'npm rebuild' (可能需要几分钟)"),
|
||||||
|
).toBe(" [binary] better-sqlite3: falling back - 'npm rebuild'");
|
||||||
|
expect(
|
||||||
|
toAsciiFallback(" Windows: npm install --global windows-build-tools (或安装 VS Build Tools)"),
|
||||||
|
).toBe(" Windows: npm install --global windows-build-tools (VS Build Tools)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a Chinese-only line, which always has an English twin beside it", () => {
|
||||||
|
expect(toAsciiFallback("必需的原生模块不可用,机器人无法启动 —— 请查看上面的错误信息。")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the indentation the summary is aligned on, and drops the dangling dash", () => {
|
||||||
|
expect(toAsciiFallback(" - better-sqlite3 FAILED — 编译失败")).toBe(
|
||||||
|
" - better-sqlite3 FAILED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a separator that belongs to the English half", () => {
|
||||||
|
expect(toAsciiFallback("Summary — Node v22.0.0 / ABI 127 / win32-x64:")).toBe(
|
||||||
|
"Summary - Node v22.0.0 / ABI 127 / win32-x64:",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createLineWriter", () => {
|
||||||
|
it("appends a newline and reports the write", () => {
|
||||||
|
const stream = fakeStream();
|
||||||
|
expect(createLineWriter(stream)("hello")).toBe(true);
|
||||||
|
expect(stream.written).toEqual(["hello\n"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives the EIO that killed setup: an 'error' event must not throw", () => {
|
||||||
|
const stream = fakeStream();
|
||||||
|
createLineWriter(stream);
|
||||||
|
expect(stream.listenerCount("error")).toBe(1);
|
||||||
|
expect(() => stream.emit("error", Object.assign(new Error("write EIO"), { code: "EIO" }))).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to ASCII once the console has refused a line", () => {
|
||||||
|
const stream = fakeStream();
|
||||||
|
const write = createLineWriter(stream);
|
||||||
|
write(" [binary] GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)");
|
||||||
|
stream.emit("error", new Error("write EIO"));
|
||||||
|
write(" [binary] GET https://cdn/opus.tar.gz (~1 MB, 这一步比较慢,请耐心等待)");
|
||||||
|
expect(stream.written).toEqual([
|
||||||
|
" [binary] GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)\n",
|
||||||
|
" [binary] GET https://cdn/opus.tar.gz (~1 MB)\n",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes quiet after a second failure rather than retrying a dead stream", () => {
|
||||||
|
const stream = fakeStream();
|
||||||
|
const write = createLineWriter(stream);
|
||||||
|
stream.emit("error", new Error("write EIO"));
|
||||||
|
stream.emit("error", new Error("write EPIPE"));
|
||||||
|
expect(write("anything at all")).toBe(false);
|
||||||
|
expect(stream.written).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades on a synchronous throw too, which never reaches the listener", () => {
|
||||||
|
const stream = fakeStream();
|
||||||
|
const write = createLineWriter(stream);
|
||||||
|
stream.throwOnWrite = true;
|
||||||
|
expect(write(" [binary] 下载中 downloading")).toBe(false);
|
||||||
|
stream.throwOnWrite = false;
|
||||||
|
write(" [binary] 下载中 downloading");
|
||||||
|
expect(stream.written).toEqual([" [binary] downloading\n"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades each stream on its own, so setup.log keeps the full transcript", () => {
|
||||||
|
const console_ = fakeStream();
|
||||||
|
const logFile = fakeStream();
|
||||||
|
const writeConsole = createLineWriter(console_);
|
||||||
|
const writeLog = createLineWriter(logFile);
|
||||||
|
console_.emit("error", new Error("write EIO"));
|
||||||
|
|
||||||
|
const line = " [binary] ffmpeg-static: 下载完成 done";
|
||||||
|
writeConsole(line);
|
||||||
|
writeLog(line);
|
||||||
|
|
||||||
|
expect(console_.written).toEqual([" [binary] ffmpeg-static: done\n"]);
|
||||||
|
expect(logFile.written).toEqual([`${line}\n`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never installs a second listener for a stream that already has a writer", () => {
|
||||||
|
const stream = fakeStream();
|
||||||
|
createLineWriter(stream);
|
||||||
|
createLineWriter(stream);
|
||||||
|
expect(stream.listenerCount("error")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still guards a stream that is not an EventEmitter", () => {
|
||||||
|
const stream = { write: vi.fn(() => { throw new Error("EBADF"); }) };
|
||||||
|
const write = createLineWriter(stream);
|
||||||
|
expect(() => write("line")).not.toThrow();
|
||||||
|
expect(write("line")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+11
-9
@@ -11,7 +11,7 @@ title TSMusicBot Setup
|
|||||||
:: ============================================================
|
:: ============================================================
|
||||||
|
|
||||||
set "SCRIPT_VERSION=2.2"
|
set "SCRIPT_VERSION=2.2"
|
||||||
set "MIN_NODE_MAJOR=20"
|
set "MIN_NODE_MAJOR=22"
|
||||||
:: Newest Node major this project is regularly tested against. Anything above
|
:: Newest Node major this project is regularly tested against. Anything above
|
||||||
:: still works, it just may have no prebuilt addons and fall back to a source build.
|
:: still works, it just may have no prebuilt addons and fall back to a source build.
|
||||||
set "TESTED_NODE_MAJOR=22"
|
set "TESTED_NODE_MAJOR=22"
|
||||||
@@ -67,13 +67,15 @@ for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
|
|||||||
call :log "Node.js version: %NODE_VER%"
|
call :log "Node.js version: %NODE_VER%"
|
||||||
echo [OK] Node.js found: %NODE_VER%
|
echo [OK] Node.js found: %NODE_VER%
|
||||||
|
|
||||||
:: The supported floor is not just a major version, so let node decide:
|
:: The supported floor is not just a major version, so let node decide.
|
||||||
:: @honeybbq/teamspeak-client needs >=20.19, @sansenjian/qq-music-api needs
|
:: Node 20 was dropped: better-sqlite3 ships no prebuilt binary for its ABI
|
||||||
:: >=20.17 / >=22.9, and the odd majors (21 / 23) are excluded by
|
:: (115) since 12.10.0, so every Node 20 install needed Python and a C++
|
||||||
:: better-sqlite3 and vitest. Keep this in sync with "engines" in package.json.
|
:: toolchain just to get off the ground (issue #152). The odd majors (21 /
|
||||||
node -e "const v=process.versions.node.split('.').map(Number); process.exit((v[0]===20&&v[1]>=19)||(v[0]===22&&v[1]>=12)||v[0]>=24?0:1)"
|
:: 23) are excluded by better-sqlite3 and vitest.
|
||||||
|
:: Keep this in sync with "engines" in package.json.
|
||||||
|
node -e "const v=process.versions.node.split('.').map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)"
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
call :error "Node.js %NODE_VER% is not supported. Use Node 20.19+ LTS or Node 22.12+ LTS."
|
call :error "Node.js %NODE_VER% is not supported. Use Node 22.12+ LTS or newer."
|
||||||
echo Download: https://nodejs.org/ or https://nodejs.cn/
|
echo Download: https://nodejs.org/ or https://nodejs.cn/
|
||||||
pause
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
@@ -86,10 +88,10 @@ if errorlevel 1 (
|
|||||||
:: characters and starts eating the "echo " prefix of following lines.
|
:: characters and starts eating the "echo " prefix of following lines.
|
||||||
:: Bilingual guidance lives in the Node scripts, which print UTF-8 reliably.
|
:: Bilingual guidance lives in the Node scripts, which print UTF-8 reliably.
|
||||||
if %NODE_MAJOR% GTR %TESTED_NODE_MAJOR% (
|
if %NODE_MAJOR% GTR %TESTED_NODE_MAJOR% (
|
||||||
echo [WARN] Node %NODE_VER% is newer than the tested LTS line, Node 20 / Node 22.
|
echo [WARN] Node %NODE_VER% is newer than the tested LTS line, Node 22.
|
||||||
echo Newer Node majors may have no prebuilt opus / better-sqlite3,
|
echo Newer Node majors may have no prebuilt opus / better-sqlite3,
|
||||||
echo so setup falls back to a source build - slower, needs C++ build tools.
|
echo so setup falls back to a source build - slower, needs C++ build tools.
|
||||||
echo Recommended: Node 20 LTS or Node 22 LTS - https://nodejs.org/ or https://nodejs.cn/
|
echo Recommended: Node 22 LTS - https://nodejs.org/ or https://nodejs.cn/
|
||||||
echo This is only a warning; setup still builds the binaries for %NODE_VER%.
|
echo This is only a warning; setup still builds the binaries for %NODE_VER%.
|
||||||
call :log "[WARN] Node major %NODE_MAJOR% is newer than tested LTS %TESTED_NODE_MAJOR%"
|
call :log "[WARN] Node major %NODE_MAJOR% is newer than tested LTS %TESTED_NODE_MAJOR%"
|
||||||
)
|
)
|
||||||
|
|||||||
+8
-6
@@ -32,16 +32,18 @@ echo "[OK] Node.js $(node -v)"
|
|||||||
TESTED_NODE_MAJOR=22
|
TESTED_NODE_MAJOR=22
|
||||||
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
|
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
|
||||||
|
|
||||||
# The floor is not just a major version, so let node decide: @honeybbq/teamspeak-client
|
# The floor is not just a major version, so let node decide. Node 20 was dropped:
|
||||||
# needs >=20.19, @sansenjian/qq-music-api needs >=20.17 / >=22.9, and the odd majors
|
# better-sqlite3 ships no prebuilt binary for its ABI (115) since 12.10.0, so every
|
||||||
# (21 / 23) are excluded by better-sqlite3 and vitest. Keep in sync with package.json "engines".
|
# Node 20 install needed Python and a C++ toolchain just to get off the ground
|
||||||
if ! node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===20&&v[1]>=19)||(v[0]===22&&v[1]>=12)||v[0]>=24?0:1)'; then
|
# (issue #152). The odd majors (21 / 23) are excluded by better-sqlite3 and vitest.
|
||||||
echo "[ERROR] Node.js $(node -v) is not supported. Use Node 20.19+ LTS or Node 22.12+ LTS."
|
# Keep in sync with package.json "engines".
|
||||||
|
if ! node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)'; then
|
||||||
|
echo "[ERROR] Node.js $(node -v) is not supported. Use Node 22.12+ LTS or newer."
|
||||||
echo " https://nodejs.org/ | https://nodejs.cn/"
|
echo " https://nodejs.org/ | https://nodejs.cn/"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ "$NODE_MAJOR" -gt "$TESTED_NODE_MAJOR" ]; then
|
if [ "$NODE_MAJOR" -gt "$TESTED_NODE_MAJOR" ]; then
|
||||||
echo "[WARN] Node $(node -v) is newer than the tested LTS line (Node 20 / Node 22)."
|
echo "[WARN] Node $(node -v) is newer than the tested LTS line (Node 22)."
|
||||||
echo " 新版 Node 可能没有现成的 opus / better-sqlite3 预编译包,"
|
echo " 新版 Node 可能没有现成的 opus / better-sqlite3 预编译包,"
|
||||||
echo " 安装时会自动改用源码编译,需要 C/C++ 构建工具,速度较慢。"
|
echo " 安装时会自动改用源码编译,需要 C/C++ 构建工具,速度较慢。"
|
||||||
echo " This is only a warning - setup builds the binaries for $(node -v) either way."
|
echo " This is only a warning - setup builds the binaries for $(node -v) either way."
|
||||||
|
|||||||
Reference in new issue
Block a user