// VPS 本机入口:一个进程同时提供静态页面与 /api/* // // 为什么不用 express/hono:core/api.js 收发的是标准 Request/Response, // Node 22 原生就有,加依赖只会让 CF Workers 那边多一层不必要的适配。 // // 环境变量: // PORT 监听端口(默认 9523,避开现有的 9522 静态服务) // HOST 监听地址(默认 0.0.0.0;已有登录鉴权,但仍建议配反代 + HTTPS) // DB_PATH SQLite 文件(默认 /data/work-hours.db) // STATIC_DIR 静态目录(默认项目根,即 index.html 所在处) // ALLOW_REGISTER=1 开放注册(默认只允许「库里还没有用户」时注册首个账号) // MCP_AUTH_TOKEN MCP 端点(POST /mcp)访问令牌。只做首次种子:库里已有令牌后以 DB 为准(后台可改) // ACCESS_LOG 访问日志文件(默认 /data/access.log;设 off 关闭) import { createServer } from 'node:http'; import { readFile, stat } from 'node:fs/promises'; import { mkdirSync, createWriteStream, statSync, renameSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { promisify } from 'node:util'; import { gzip, brotliCompress, constants as zlibConstants } from 'node:zlib'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve, normalize, extname } from 'node:path'; import { SqliteStore } from './adapters/sqlite-node.js'; import { handleApi } from './core/api.js'; import { handleMcp } from './core/mcp.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT || 9523); const HOST = process.env.HOST || '0.0.0.0'; const DB_PATH = process.env.DB_PATH || join(__dirname, 'data', 'work-hours.db'); const STATIC_DIR = resolve(process.env.STATIC_DIR || join(__dirname, '..')); const ALLOW_REGISTER = process.env.ALLOW_REGISTER === '1'; const MCP_SEED_TOKEN = process.env.MCP_AUTH_TOKEN || ''; const ACCESS_LOG = process.env.ACCESS_LOG === 'off' ? null : resolve(process.env.ACCESS_LOG || join(__dirname, 'data', 'access.log')); mkdirSync(dirname(DB_PATH), { recursive: true }); const store = new SqliteStore(DB_PATH); // ===== 访问日志 ===== // 存在的理由很具体:出现过「页面到了、167KB 的 vue.global.prod.js 没到」这类单请求失败, // 服务端却完全查不到那次请求,只能靠猜。记下状态码与实发字节数,下次同类问题可直接对账: // 有记录且 bytes 完整 → 问题在客户端/链路;没记录 → 请求根本没到服务器。 // 只记这一台自用服务需要的最小字段,不记 cookie / Authorization / 请求体。 const LOG_MAX_BYTES = 8 * 1024 * 1024; // 超过就滚动一次,避免无人看管长成巨型文件 let logStream = null; function openLog() { if (!ACCESS_LOG) return; mkdirSync(dirname(ACCESS_LOG), { recursive: true }); try { if (statSync(ACCESS_LOG).size > LOG_MAX_BYTES) renameSync(ACCESS_LOG, ACCESS_LOG + '.1'); } catch { /* 文件不存在,正常 */ } logStream = createWriteStream(ACCESS_LOG, { flags: 'a' }); // 日志写不动绝不能拖垮服务,出错就退化成「不记日志」继续跑。 logStream.on('error', e => { console.error('[access-log] 写入失败,已停用日志:', e.message); logStream = null; }); } openLog(); function clientIp(req) { const fwd = req.headers['x-forwarded-for']; if (typeof fwd === 'string' && fwd) return fwd.split(',')[0].trim(); return req.socket.remoteAddress || '-'; } /** * 记一条访问。 * status 用 '-' 表示连响应头都没发出去(客户端中途断开时会这样)。 * sent 是本次响应实际写出的字节数(含响应头,所以比 want 大几百字节是正常的),want 是 body 应发字节数。 * sent 明显小于 want 就是传输被截断——这正是 167KB 的 Vue 只到一半那类问题要抓的证据。 * @param {import('node:net').Socket|null} sock 开头就存下的 socket 引用:res.socket 在 close 时已经置空,现场取不到。 * @param {number} baseline 该 socket 在本次请求前已写字节数;keep-alive 下一条连接跑多个请求,不减就会累加。 */ function writeAccess(req, res, startedAt, sock, baseline) { if (!logStream) return; const ms = Date.now() - startedAt; const status = res.headersSent ? res.statusCode : '-'; const sent = sock ? Math.max(0, sock.bytesWritten - baseline) : 0; // 不能用 res.getHeader('content-length'):经 writeHead(status, obj) 设的头不进可查询的头集合, // 读出来永远是 undefined(实测过,最初写成那样日志里全是 want=-)。改由写响应的地方直接标上。 const want = res.wantBytes ?? '-'; const ua = String(req.headers['user-agent'] || '-').replace(/["\n\r]/g, ' ').slice(0, 200); const line = [ new Date().toISOString(), clientIp(req), req.method, (req.url || '-').slice(0, 300), status, `sent=${sent}`, `want=${want}`, ms + 'ms', res.writableFinished ? 'done' : 'aborted', '"' + ua + '"' ].join(' '); logStream.write(line + '\n'); } const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json' }; // 静态目录里混着源码、数据库、备份和调试产物,逐类挡掉。 // vendor/ 是页面真实依赖(tailwind.css / vue.global.prod.js),不能挡。 // shared/ 同样不能挡:payroll.js 是前端要