Files
TankIC/3DTank/tools/serve.js
T
TermiNexus 2b95e5de28 chore(3DTank): 新增页面内探针的网络转储通道(/__probe__ + probe_railgun.js)
- tools/serve.js: POST /__probe__ 追加写 3DTank/names/probe_railgun.log
- sockets/probe_railgun.js: 电磁炮/武器链路现场探针(组件清单 + 采样 + WS 上行帧),fetch 批量转储,POST 不通自动回落控制台
2026-10-12 10:13:32 +08:00

321 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// serve.js - 本地静态服务器:把镜像目录当作 3dtank.com 的站点根来跑
// 用法: node serve.js [--port 8080] [--web web] [--no-proxy] [--verbose]
// --verbose 打印每一个请求(含状态码/来源),排查页面加载问题时用
// 挂载:
// / → 3DTank/web/(index.html + play/static 资源)
// /play/static/js/ → TankIC/3dtank.com/play/static/js/(切割版 JS,单一来源,不重复拷贝)
// 代理(关键):游戏在非 3dtank.com 域名下会把配置指向本机,
// 见 src/module_13431.js:1704 分支 → resources=http://本机/resources、config-template=http://本机/config.xml
// 因此这里把这三个路径转发到真实站点,并强制带上 origin/referer(CDN 会校验),同时落盘缓存。
// 记录: 所有 404 会打印并追加到 3DTank/names/missing.txt,方便补齐缺失资源
const fs = require('fs');
const http = require('http');
const path = require('path');
const PROJECT = path.resolve(__dirname, '..');
const ROOT = path.resolve(PROJECT, '..');
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const PORT = Number(Arg('port', '8080'));
const WEB_DIR = path.resolve(Arg('web', path.join(PROJECT, 'web')));
const JS_DIR = path.join(ROOT, '3dtank.com', 'play', 'static', 'js');
const MISSING_LOG = path.join(PROJECT, 'names', 'missing.txt');
// 页面内探针(sockets/probe_railgun.js)的网络转储落盘位置:POST /__probe__ 追加写这里
const PROBE_LOG = path.join(PROJECT, 'names', 'probe_railgun.log');
const CACHE_DIR = path.join(PROJECT, 'cache');
const NO_PROXY = Argv.indexOf('--no-proxy') >= 0;
const VERBOSE = Argv.indexOf('--verbose') >= 0;
// /config.xml 本地覆盖:local/config.xml 存在时用它(把游戏指向本地 wss_server 9091),
// 加 --real-config 可强制走真实服务器
const LOCAL_CONFIG = path.join(PROJECT, 'local', 'config.xml');
const REAL_CONFIG = Argv.indexOf('--real-config') >= 0;
// --ws-hook:服务 index.html 时注入页面内 ws 抓包脚本(sockets/ws_hook.js)。
// 刻意用**启动参数**而不是 URL 开关:游戏用 location.hash 做路由/状态,
// 任何 hash(哪怕只是 #hook)都会让它进不去战场。
const WS_HOOK = Argv.indexOf('--ws-hook') >= 0;
// 本机路径 → 真实站点(StripPrefix: 去掉本机前缀再拼到 Target 上;KeepQuery: 是否保留查询串)
const PROXY = [
{ Prefix: '/resources', Target: 'https://res.3dtank.com', StripPrefix: true, KeepQuery: true },
{ Prefix: '/config.xml', Target: 'https://c1.3dtank.com', StripPrefix: false, KeepQuery: false },
{ Prefix: '/s/', Target: 'https://3dtank.com', StripPrefix: false, KeepQuery: false },
];
// 代理回源头:Origin 按规范只到源(不带路径),Referer 才是完整地址
const UPSTREAM_HEADERS = {
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0',
'accept-language': 'zh-CN,zh;q=0.9,en-GB;q=0.8,en;q=0.7,en-US;q=0.6',
origin: 'https://3dtank.com',
referer: 'https://3dtank.com/play/',
};
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
// config.xml 必须给 XML 类型:客户端用 responseType="document" 读取,
// 非 XML 类型时 responseXML 恒为 null → 抛 NullPointerException(module_61860 onload)
'.xml': 'application/xml; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.jpg': 'image/jpeg',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.mp3': 'audio/mpeg',
'.ogg': 'audio/ogg',
'.webm': 'video/webm',
'.mp4': 'video/mp4',
'.wasm': 'application/wasm',
'.txt': 'text/plain; charset=utf-8',
'.3ds': 'application/octet-stream',
'.bin': 'application/octet-stream',
};
function Resolve(UrlPath) {
const Clean = decodeURIComponent(UrlPath.split('?')[0]);
// 切割版 JS 走 TankIC 目录
if (Clean.startsWith('/play/static/js/')) {
return path.join(JS_DIR, Clean.slice('/play/static/js/'.length));
}
const Rel = Clean.replace(/^\/+/, '');
return Rel === '' ? path.join(WEB_DIR, 'index.html') : path.join(WEB_DIR, Rel);
}
if (!fs.existsSync(MISSING_LOG)) {
fs.mkdirSync(path.dirname(MISSING_LOG), { recursive: true });
fs.writeFileSync(MISSING_LOG, '', 'utf8');
} else {
// 每次启动清空,只保留本次会话的 404,避免旧记录干扰排查
fs.writeFileSync(MISSING_LOG, '', 'utf8');
}
function MatchProxy(UrlPath) {
if (NO_PROXY) return null;
const Q = UrlPath.indexOf('?');
const PathPart = Q >= 0 ? UrlPath.slice(0, Q) : UrlPath;
const Query = Q >= 0 ? UrlPath.slice(Q) : '';
for (const Rule of PROXY) {
const NoSlash = Rule.Prefix.replace(/\/$/, '');
if (PathPart !== NoSlash && !PathPart.startsWith(Rule.Prefix)) continue;
const Rest = Rule.StripPrefix ? PathPart.slice(NoSlash.length) : PathPart;
return { Url: Rule.Target + (Rest.startsWith('/') ? Rest : '/' + Rest) + (Rule.KeepQuery ? Query : '') };
}
return null;
}
const ProxyStats = { total: 0, cached: 0, failed: 0 };
function CachePath(Url) {
const U = new URL(Url);
const Rel = (U.hostname + U.pathname).replace(/[^\w.\-/]/g, '_');
return path.join(CACHE_DIR, Rel + (U.search ? '_' + Buffer.from(U.search).toString('hex').slice(0, 12) : ''));
}
async function HandleProxy(Rule, Req, Res) {
ProxyStats.total++;
if (ProxyStats.total % 50 === 0) {
console.log('[proxy] 累计 ' + ProxyStats.total + ' 次(命中缓存 ' + ProxyStats.cached + ',失败 ' + ProxyStats.failed + ')');
}
const CacheFile = Rule.SaveFile || CachePath(Rule.Url);
if (VERBOSE) console.log('[代理] ' + Rule.Url);
try {
if (fs.existsSync(CacheFile)) {
ProxyStats.cached++;
const Buf = fs.readFileSync(CacheFile);
Res.writeHead(200, {
'Content-Type': MIME[path.extname(new URL(Rule.Url).pathname).toLowerCase()] || 'application/octet-stream',
'Content-Length': Buf.length,
'Cache-Control': 'no-cache',
'X-Hornet-Proxy': 'cache',
});
Res.end(Buf);
return;
}
const Upstream = await fetch(Rule.Url, { headers: UPSTREAM_HEADERS, redirect: 'follow' });
const Buf = Buffer.from(await Upstream.arrayBuffer());
if (!Upstream.ok) {
ProxyStats.failed++;
console.log('[proxy] ' + Upstream.status + ' ' + Rule.Url);
Res.writeHead(Upstream.status, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Upstream ' + Upstream.status);
return;
}
fs.mkdirSync(path.dirname(CacheFile), { recursive: true });
fs.writeFileSync(CacheFile, Buf);
Res.writeHead(200, {
// 优先按扩展名给类型(config.xml 必须是 XML,否则 responseXML 为 null)
'Content-Type':
MIME[path.extname(new URL(Rule.Url).pathname).toLowerCase()] ||
Upstream.headers.get('content-type') ||
'application/octet-stream',
'Content-Length': Buf.length,
'Cache-Control': 'no-cache',
'X-Hornet-Proxy': 'upstream',
});
Res.end(Buf);
} catch (Err) {
ProxyStats.failed++;
console.log('[proxy] ERR ' + Rule.Url + ' : ' + Err.message);
Res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Proxy error: ' + Err.message);
}
}
const Server = http.createServer((Req, Res) => {
const UrlPath = Req.url.split('#')[0];
// 浏览器 → 本地落盘:控制台脚本把 dump 结果 POST 上来,写到 names/ 下
// 用法(控制台):fetch('/__dump__/regdump.json', {method:'POST', body: JSON.stringify(x)})
if (Req.method === 'POST' && UrlPath.startsWith('/__dump__/')) {
const Name = path.basename(decodeURIComponent(UrlPath.slice('/__dump__/'.length))).replace(/[^\w.\-]/g, '_') || 'dump.json';
const Out = path.join(PROJECT, 'names', Name);
const Chunks = [];
Req.on('data', (C) => Chunks.push(C));
Req.on('end', () => {
try {
const Buf = Buffer.concat(Chunks);
fs.mkdirSync(path.dirname(Out), { recursive: true });
fs.writeFileSync(Out, Buf);
console.log('[dump] 已写入 ' + Out + '(' + Buf.length + ' 字节)');
Res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }).end('saved ' + Name + ' ' + Buf.length);
} catch (Err) {
console.log('[dump] 写入失败: ' + Err.message);
Res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }).end('write failed: ' + Err.message);
}
});
return;
}
// 某些插件/缓存会来探 Vite HMR 客户端,给个空模块避免报错
if (UrlPath.startsWith('/@vite/')) {
Res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }).end('/* noop */\n');
return;
}
// /hornet_sw.js:Service Worker 本体。放在根路径 + 放行 Service-Worker-Allowed,
// 作用域才能覆盖 /,从而接管首页 / 发起的 /play/... 碎请求(unpacked 单包缓存)
if (UrlPath.split('?')[0] === '/hornet_sw.js') {
const SwFile = path.join(ROOT, 'tools', 'hornet_sw.js');
if (!fs.existsSync(SwFile)) {
Res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('hornet_sw.js not found');
return;
}
const Sw = fs.readFileSync(SwFile);
Res.writeHead(200, {
'Content-Type': 'application/javascript; charset=utf-8',
'Content-Length': Sw.length,
'Cache-Control': 'no-cache',
'Service-Worker-Allowed': '/',
'X-Hornet-Sw': 'local',
}).end(Sw);
return;
}
// /ws_hook.js:页面内 window.WebSocket 抓包脚本(旁听,不改传输/不改模块加载)。
// 由 index.html 在主脚本之前引入;脚本自身默认不启用,URL 加 #hook 才抓。
if (UrlPath.split('?')[0] === '/ws_hook.js') {
const HookFile = path.join(ROOT, 'sockets', 'ws_hook.js');
if (!fs.existsSync(HookFile)) {
Res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('ws_hook.js not found');
return;
}
const Hook = fs.readFileSync(HookFile);
Res.writeHead(200, {
'Content-Type': 'application/javascript; charset=utf-8',
'Content-Length': Hook.length,
'Cache-Control': 'no-cache',
'X-Hornet-WsHook': 'local',
}).end(Hook);
return;
}
// /__probe__:页面内探针的**网络转储**入口(控制台刷屏太扰,改为 POST 落盘)
// 探针(sockets/probe_railgun.js)把组件清单与采样分批 POST 到这里,
// 这里追加写入 3DTank/names/probe_railgun.log,供离线分析。
if (UrlPath.split('?')[0] === '/__probe__') {
if (Req.method !== 'POST') {
Res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' }).end('POST only');
return;
}
const Chunks = [];
Req.on('data', (C) => Chunks.push(C));
Req.on('end', () => {
const Body = Buffer.concat(Chunks).toString('utf8');
try {
fs.appendFileSync(PROBE_LOG, Body.endsWith('\n') ? Body : Body + '\n');
} catch (Err) {
console.warn('[probe] 落盘失败: ' + Err.message);
}
Res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-cache' }).end('ok');
});
return;
}
// /config.xml 本地覆盖:存在 local/config.xml 时用它,把游戏指向本地 wss_server
if (!REAL_CONFIG && UrlPath.split('?')[0] === '/config.xml' && fs.existsSync(LOCAL_CONFIG)) {
const Conf = fs.readFileSync(LOCAL_CONFIG);
Res.writeHead(200, {
'Content-Type': 'application/xml; charset=utf-8',
'Content-Length': Conf.length,
'Cache-Control': 'no-cache',
'X-Hornet-Config': 'local',
}).end(Conf);
return;
}
const Rule = MatchProxy(UrlPath);
if (Rule) {
HandleProxy(Rule, Req, Res);
return;
}
const File = Resolve(UrlPath);
if (!File.startsWith(WEB_DIR) && !File.startsWith(JS_DIR)) {
Res.writeHead(403).end('Forbidden');
return;
}
fs.stat(File, (Err, Stat) => {
if (Err || !Stat.isFile()) {
// 自愈:本地没有的静态资源自动回源 3dtank.com 并落盘缓存(GET 才回源)
if (Req.method === 'GET' && !NO_PROXY) {
console.log('[回源] ' + UrlPath);
HandleProxy({ Url: 'https://3dtank.com' + UrlPath }, Req, Res);
return;
}
fs.appendFile(MISSING_LOG, UrlPath + '\n', () => {});
console.log('[404] ' + UrlPath);
Res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Not Found: ' + UrlPath);
return;
}
const Ext = path.extname(File).toLowerCase();
const Type = MIME[Ext] || 'application/octet-stream';
// --ws-hook:HTML 里注入抓包脚本(在主脚本之前 → 先于游戏创建 WebSocket)
if (WS_HOOK && (Ext === '.html' || Ext === '.htm')) {
const Html = fs.readFileSync(File, 'utf8').replace(/<\/head>/i, '<script src="/ws_hook.js"></script></head>');
const Buf = Buffer.from(Html, 'utf8');
Res.writeHead(200, { 'Content-Type': Type, 'Content-Length': Buf.length, 'Cache-Control': 'no-cache' });
if (VERBOSE) console.log('[200] ' + Buf.length + '\t' + UrlPath + ' (已注入 ws_hook)');
Res.end(Buf);
return;
}
Res.writeHead(200, {
'Content-Type': Type,
'Content-Length': Stat.size,
'Cache-Control': 'no-cache',
});
if (VERBOSE) console.log('[200] ' + Stat.size + '\t' + UrlPath);
fs.createReadStream(File).pipe(Res);
});
});
process.on('uncaughtException', (Err) => console.log('[server error] ' + Err.message));
Server.listen(PORT, () => {
console.log('本地客户端: http://localhost:' + PORT + '/');
console.log(' 站点根 : ' + WEB_DIR);
console.log(' 切割版 JS : ' + JS_DIR);
console.log(' 404 记录 : ' + MISSING_LOG);
console.log(' config.xml: ' + (!REAL_CONFIG && fs.existsSync(LOCAL_CONFIG) ? '本地覆盖 ' + LOCAL_CONFIG : '回源 c1.3dtank.com(--real-config)'));
console.log(' ws 抓包 : ' + (WS_HOOK ? '已开启(HTML 注入 /ws_hook.js;保存用 __ws_save__())' : '关闭(要抓包加 --ws-hook,勿用 URL hash)'));
});