Files
TankIC/3DTank/tools/serve.js
T
TermiNexus cca13974de fix(relay): 上游对齐官方 —— config 回源与中继上游改为 c2
- serve.js:新增 --config-upstream(默认 https://c2.3dtank.com),/config.xml 回源与改写底稿都改用它

- wss_server.js / Start-LocalAndRelay.ps1:上游默认 c1 改为 c2,可用 -Upstream / --upstream 切回

- 依据:客户端 JS 无 c1/c2 硬编码(连谁完全由 config 决定);实测 c1/c2 各自提供指向自己的 config,官方客户端实际连 c2
2026-10-15 14:09:25 +08:00

409 lines
19 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');
// 页面内探针的网络转储落盘位置:POST /__probe__?f=<name> 追加写对应文件(不传 f 默认 railgun)
const PROBE_LOGS = {
railgun: path.join(PROJECT, 'names', 'probe_railgun.log'),
chassis: path.join(PROJECT, 'names', 'probe_chassis.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;
// 中继模式:--relay-port <n> 时,/config.xml **动态改写**为指向本机 ws://localhost:<n>/
// (每次实时生成,不落盘、不依赖 local/config.xml;address→localhost、wsProtocol→WS、port→n)
const RELAY_PORT = Number(Arg('relay-port', '0')) || 0;
// --ws-hook:服务 index.html 时注入页面内 ws 抓包脚本(sockets/ws_hook.js)。
// 刻意用**启动参数**而不是 URL 开关:游戏用 location.hash 做路由/状态,
// 任何 hash(哪怕只是 #hook)都会让它进不去战场。
const WS_HOOK = Argv.indexOf('--ws-hook') >= 0;
// config.xml 的回源目标:官方客户端实际连的是 **c2**(c1/c2 是两个独立服,各自提供自己的 config)
// 可用 --config-upstream 覆盖(例如 https://c1.3dtank.com)
const CONFIG_UPSTREAM = Arg('config-upstream', 'https://c2.3dtank.com').replace(/\/+$/, '');
// 本机路径 → 真实站点(StripPrefix: 去掉本机前缀再拼到 Target 上;KeepQuery: 是否保留查询串)
const PROXY = [
{ Prefix: '/resources', Target: 'https://res.3dtank.com', StripPrefix: true, KeepQuery: true },
{ Prefix: '/config.xml', Target: CONFIG_UPSTREAM, 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);
}
}
// 中继模式:取真实 config.xml(本地缓存优先,否则回源 CONFIG_UPSTREAM),把 address/wsProtocol/port 改写成
// 指向本机中继 ws://localhost:<RELAY_PORT>/,每次实时生成返回给客户端(不落盘)。
async function ServeRewrittenConfig(Res) {
const UpHost = new URL(CONFIG_UPSTREAM).hostname;
const CacheFile = path.join(CACHE_DIR, UpHost, 'config.xml');
let Xml = '';
try {
if (fs.existsSync(CacheFile)) {
Xml = fs.readFileSync(CacheFile, 'utf8');
} else {
const Up = await fetch(CONFIG_UPSTREAM + '/config.xml', { headers: UPSTREAM_HEADERS, redirect: 'follow' });
if (Up.ok) Xml = await Up.text();
}
} catch (Err) {
console.log('[relay-config] 取真实 config 失败: ' + Err.message);
}
if (!Xml || Xml.indexOf('<server') < 0) {
// 兜底模板(与 c1 实际内容同构)
Xml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cfg xmlns="http://alternativaplatform.com/core/config.xsd"><server address="c1.3dtank.com" version="" mode="simple" wsProtocol="WSS"><ports/><webSocketPorts><port>9090</port></webSocketPorts></server><status>normal</status></cfg>';
}
const Out = Xml
.replace(/(<server\b[^>]*\baddress=")[^"]*(")/, '$1localhost$2')
.replace(/(<server\b[^>]*\bwsProtocol=")[^"]*(")/, '$1WS$2')
.replace(/(<webSocketPorts>\s*<port>)\d+(<\/port>)/, '$1' + RELAY_PORT + '$2');
const Buf = Buffer.from(Out, 'utf8');
Res.writeHead(200, {
'Content-Type': 'application/xml; charset=utf-8',
'Content-Length': Buf.length,
'Cache-Control': 'no-cache',
'X-Hornet-Config': 'relay',
});
Res.end(Buf);
}
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_railgun.js:武器链路现场探针(只读采样 + WS 上行记录,数据 POST /__probe__ 落盘)
// 由加载器自动引入(localStorage.hornetProbe='0' 可关闭),省去每次手粘控制台。
if (UrlPath.split('?')[0] === '/probe_railgun.js') {
const ProbeFile = path.join(ROOT, 'sockets', 'probe_railgun.js');
if (!fs.existsSync(ProbeFile)) {
Res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('probe_railgun.js not found');
return;
}
const Probe = fs.readFileSync(ProbeFile);
Res.writeHead(200, {
'Content-Type': 'application/javascript; charset=utf-8',
'Content-Length': Probe.length,
'Cache-Control': 'no-cache',
'X-Hornet-Probe': 'local',
}).end(Probe);
return;
}
// /probe_chassis.js:十字军/悬浮底盘「旋转 + 移动」控制链路探针(数据 POST /__probe__?f=chassis)
if (UrlPath.split('?')[0] === '/probe_chassis.js') {
const ProbeFile = path.join(ROOT, 'sockets', 'probe_chassis.js');
if (!fs.existsSync(ProbeFile)) {
Res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('probe_chassis.js not found');
return;
}
const Probe = fs.readFileSync(ProbeFile);
Res.writeHead(200, {
'Content-Type': 'application/javascript; charset=utf-8',
'Content-Length': Probe.length,
'Cache-Control': 'no-cache',
'X-Hornet-Probe': 'local',
}).end(Probe);
return;
}
// /__probe__:页面内探针的**网络转储**入口(控制台刷屏太扰,改为 POST 落盘)
// 探针把组件清单与采样分批 POST 到这里;?f=<name> 选择落盘文件(railgun / chassis),默认 railgun。
if (UrlPath.split('?')[0] === '/__probe__') {
if (Req.method !== 'POST') {
Res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' }).end('POST only');
return;
}
const FMatch = /(?:^|&)f=([a-z0-9_]+)/i.exec(UrlPath.split('?')[1] || '');
const LogFile = PROBE_LOGS[FMatch ? FMatch[1].toLowerCase() : 'railgun'] || PROBE_LOGS.railgun;
const Chunks = [];
Req.on('data', (C) => Chunks.push(C));
Req.on('end', () => {
const Body = Buffer.concat(Chunks).toString('utf8');
try {
fs.appendFileSync(LogFile, 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 中继模式:动态改写为指向本机 ws://localhost:<RELAY_PORT>/(不落盘、不依赖 local/config.xml)
if (RELAY_PORT > 0 && UrlPath.split('?')[0] === '/config.xml') {
ServeRewrittenConfig(Res).catch((Err) => {
console.log('[relay-config] 生成失败: ' + Err.message);
if (!Res.headersSent) Res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
Res.end('relay-config error: ' + Err.message);
});
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: ' + (RELAY_PORT > 0
? ('中继改写 → ws://localhost:' + RELAY_PORT + '/(动态生成,不落盘)')
: (!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)'));
});