// 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= 追加写对应文件(不传 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 时,/config.xml **动态改写**为指向本机 ws://localhost:/ // (每次实时生成,不落盘、不依赖 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/')) { const P = path.join(JS_DIR, Clean.slice('/play/static/js/'.length)); // 主文件名的 hash 随 bundle 版本变化,而 index.html 里写的是固定名(重新拆分后会失配 → 404)。 // 这里兜底:请求 main..js 但该文件不存在时,回退到 JS 目录里最新的 main..js。 if (/[/\\]main\.[0-9a-f]{8}\.js$/.test(P) && !fs.existsSync(P)) { const Newest = fs .readdirSync(JS_DIR) .filter((F) => /^main\.[0-9a-f]{8}\.js$/.test(F)) .map((F) => ({ F, T: fs.statSync(path.join(JS_DIR, F)).mtimeMs })) .sort((A, B) => B.T - A.T)[0]; if (Newest) { console.log('[serve] 主文件失配,回退到最新: ' + path.basename(P) + ' → ' + Newest.F); return path.join(JS_DIR, Newest.F); } } return P; } 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:/,每次实时生成返回给客户端(不落盘)。 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(']*\baddress=")[^"]*(")/, '$1localhost$2') .replace(/(]*\bwsProtocol=")[^"]*(")/, '$1WS$2') .replace(/(\s*)\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; } // 本地镜像已禁用 Service Worker。 // 对任何还在注册的旧 SW(hornet_sw / 游戏自带 service-worker.js)返回一份"自注销"脚本: // 浏览器下次更新检查拉到新脚本后会 activate → unregister() → 刷新受控页面, // 避免旧 SW 的 fetch 拦截把导航请求搞成 "network error response"。 const SwPath = UrlPath.split('?')[0]; if (SwPath === '/hornet_sw.js' || SwPath === '/service-worker.js' || SwPath === '/play/service-worker.js') { const SelfKill = [ "self.addEventListener('install', function () { self.skipWaiting(); });", "self.addEventListener('activate', function (e) {", " e.waitUntil(self.registration.unregister().then(function () {", " return self.clients.matchAll({ type: 'window' });", " }).then(function (cs) {", " cs.forEach(function (c) { try { c.navigate(c.url); } catch (err) {} });", " }));", "});", "// 不调用 respondWith:请求走浏览器默认网络,不做任何拦截", "self.addEventListener('fetch', function () {});", "", ].join('\n'); Res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Content-Length': Buffer.byteLength(SelfKill), 'Cache-Control': 'no-store', 'Service-Worker-Allowed': '/', 'X-Hornet-Sw': 'disabled', }).end(SelfKill); 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= 选择落盘文件(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:/(不落盘、不依赖 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, ''); 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)')); });