433 lines
20 KiB
JavaScript
433 lines
20 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');
|
||
// 页面内探针的网络转储落盘位置: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/')) {
|
||
const P = path.join(JS_DIR, Clean.slice('/play/static/js/'.length));
|
||
// 主文件名的 hash 随 bundle 版本变化,而 index.html 里写的是固定名(重新拆分后会失配 → 404)。
|
||
// 这里兜底:请求 main.<hash>.js 但该文件不存在时,回退到 JS 目录里最新的 main.<hash>.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:<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;
|
||
}
|
||
// 本地镜像已禁用 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=<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)'));
|
||
});
|