- tools/serve.js: POST /__probe__ 追加写 3DTank/names/probe_railgun.log - sockets/probe_railgun.js: 电磁炮/武器链路现场探针(组件清单 + 采样 + WS 上行帧),fetch 批量转储,POST 不通自动回落控制台
321 lines
14 KiB
JavaScript
321 lines
14 KiB
JavaScript
// 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)'));
|
||
});
|