Files
TankIC/tools/split.js
T
TermiNexus 4005213226 feat(tools): 新版本工具链移植 + 本地镜像单包加载,中继改为原样直发与异步有序落盘
- hornet 工具链:无 ght 启动;透视冻结节点拖拽跟随、金箱标记、性能优化;雷达死亡红叉;鼠标稳定器;窗口布局持久化

- 本地镜像:切割与单包加载(pack_unpacked.js)+ SW 单包缓存 + F5 兜底刷新;入口更新为 main.9f4e7a89.js

- 中继 wss_server.js:转发改 socket 反压的原样直发(零等待零缓存);落盘改单条有序异步队列(默认开启);新增反压看门狗(默认 60s)
2026-10-11 19:35:54 +08:00

428 lines
17 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.
// split.js - 将 main.<hash>.js 切割为多文件加载结构
// 优化: 资源模块(标准 t.exports=i.p+"..." 格式)用 JSON + 模板替代,不生成独立文件
// 用法: node split.js [bundle路径] [输出目录] [--clean] [--no-hornet]
// 未指定 bundle 时自动取项目根目录下最新的 main.<hash>.js
// 未指定输出目录时默认 3dtank.com/play/static/js
// --clean 先删除旧产物(unpacked/module_*.js、resources.json、旧 main.*.js;保留 hornet_*.js)
// --no-hornet 不在主文件末尾注入 hornet_bundle 自动加载
// 输出:
// <输出目录>/main.<hash>.js (主文件: 加载器 + runtime)
// <输出目录>/unpacked/resources.json (资源模块 ID→路径映射)
// <输出目录>/unpacked/module_<id>.js (仅代码模块)
const fs = require('fs');
const path = require('path');
const acorn = require('acorn');
const walk = require('acorn-walk');
const prettier = require('prettier');
const ROOT = path.resolve(__dirname, '..');
// 自动取最新的 main.<hash>.js(排除输出目录里的加载器)
function AutoDetectBundle() {
const cands = fs.readdirSync(ROOT)
.filter((f) => /^main\.[0-9a-f]+\.js$/.test(f))
.map((f) => path.join(ROOT, f))
.filter((f) => fs.statSync(f).isFile())
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
return cands[0] || null;
}
const argv = process.argv.slice(2);
const flags = argv.filter((a) => a.startsWith('--'));
const positional = argv.filter((a) => !a.startsWith('--'));
const CLEAN = flags.indexOf('--clean') >= 0;
const WITH_HORNET = flags.indexOf('--no-hornet') < 0;
const LOADER_ONLY = flags.indexOf('--loader-only') >= 0; // 只重生成主文件,复用已有模块文件
const autoBundle = positional[0] ? null : AutoDetectBundle();
const BUNDLE_PATH = path.resolve(positional[0] || autoBundle || '');
const OUT_DIR = path.resolve(positional[1] || path.join(ROOT, '3dtank.com', 'play', 'static', 'js'));
const UNPACKED_DIR = path.join(OUT_DIR, 'unpacked');
const MAIN_PATH = BUNDLE_PATH ? path.join(OUT_DIR, path.basename(BUNDLE_PATH)) : '';
const RESOURCES_JSON_PATH = path.join(UNPACKED_DIR, 'resources.json');
// UMD 包装模板
const UMD_PREFIX = `var __script_base_url__=(typeof document!=="undefined"&&document.currentScript&&document.currentScript.src)?document.currentScript.src.replace(/[^\\/]+$/,""):"./";!function(t,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?exports.Launcher=n():t.Launcher=n()}(globalThis,async()=>{`;
const UMD_SUFFIX = (bundlePath) => `})//# sourceMappingURL=${path.basename(bundlePath)}.map\n`;
// 资源模块识别:标准格式 (t,n,i)=>{"use strict";t.exports=i.p+"path"}
// 提取 path
function tryExtractResource(valueSrc) {
// 标准格式: t.exports = i.p + "..."
const m = valueSrc.match(
/\{\s*"use strict";\s*(?:t|n|i)\.exports\s*=\s*(?:t|n|i)\.p\+"([^"]+)"\s*;?\s*\}/
);
if (m) return { type: 'std', path: m[1] };
// 变体: t.exports = i.p + "..." (无 use strict 或无大括号)
const m2 = valueSrc.match(
/(?:t|n|i)\.exports\s*=\s*(?:t|n|i)\.p\+"([^"]+)"\s*;?/
);
if (m2 && valueSrc.length < 200) return { type: 'std', path: m2[1] };
return null;
}
(async () => {
if (!BUNDLE_PATH || !fs.existsSync(BUNDLE_PATH)) {
console.error('找不到待切割的 bundle:' + (BUNDLE_PATH || '(未自动探测到 main.<hash>.js)'));
process.exit(1);
}
if (!fs.existsSync(UNPACKED_DIR)) fs.mkdirSync(UNPACKED_DIR, { recursive: true });
if (CLEAN) {
console.log('[0/7] 清理旧产物...');
let removed = 0;
for (const dir of [UNPACKED_DIR, OUT_DIR]) {
if (!fs.existsSync(dir)) continue;
for (const f of fs.readdirSync(dir)) {
const isModule = /^module_\d+\.js$/.test(f);
const isResources = f === 'resources.json';
const isOldMain = /^main\.[0-9a-f]+\.js$/.test(f) && path.join(dir, f) !== MAIN_PATH;
if (isModule || isResources || isOldMain) {
fs.unlinkSync(path.join(dir, f));
removed++;
}
}
}
console.log(` 已删除 ${removed} 个旧文件(保留 hornet_*.js)`);
}
console.log('[1/7] 读取原 bundle...');
console.log(` 文件: ${BUNDLE_PATH}`);
const source = fs.readFileSync(BUNDLE_PATH, 'utf8');
console.log(` 大小: ${(source.length / 1024 / 1024).toFixed(2)} MB`);
console.log('[2/7] 解析 AST...');
const ast = acorn.parse(source, {
ecmaVersion: 'latest',
sourceType: 'script',
allowReturnOutsideFunction: true,
});
let modulesDecl = null;
walk.simple(ast, {
VariableDeclarator(node) {
if (node.id && node.id.name === '__webpack_modules__' && node.init && node.init.type === 'ObjectExpression') {
modulesDecl = node;
}
},
});
if (!modulesDecl) throw new Error('未找到 __webpack_modules__');
const objStart = modulesDecl.init.start;
const objEnd = modulesDecl.init.end;
console.log('[3/7] 分类提取模块 (资源模块 vs 代码模块)...');
const props = modulesDecl.init.properties;
console.log(` 共 ${props.length} 个模块`);
const resourceMap = {}; // id -> path (资源模块)
const codeModuleIds = []; // 代码模块 ID 列表
let idx = 0;
let failCount = 0;
let resourceCount = 0;
let codeCount = 0;
for (const prop of props) {
idx++;
// 计算 ID
let keyRaw;
let keyNum;
if (prop.key.type === 'Identifier') {
keyRaw = prop.key.name;
keyNum = Number(keyRaw);
if (isNaN(keyNum)) keyNum = keyRaw;
} else if (prop.key.type === 'Literal') {
keyRaw = String(prop.key.value);
keyNum = typeof prop.key.value === 'number' ? prop.key.value : keyRaw;
} else {
keyRaw = `unknown_${idx}`;
keyNum = keyRaw;
}
const valueSrc = source.slice(prop.value.start, prop.value.end);
// 尝试识别为资源模块
const resource = tryExtractResource(valueSrc);
if (resource) {
// 资源模块:存入 resourceMap,不生成独立文件
const idKey = typeof keyNum === 'number' ? keyNum : keyRaw;
resourceMap[idKey] = resource.path;
resourceCount++;
continue;
}
// 代码模块:美化并写入独立文件
const paddedId = typeof keyNum === 'number' ? String(keyNum).padStart(5, '0') : keyRaw;
const idLiteral = typeof keyNum === 'number' ? keyNum : JSON.stringify(keyRaw);
if (LOADER_ONLY && fs.existsSync(path.join(UNPACKED_DIR, `module_${paddedId}.js`))) {
codeModuleIds.push(keyNum);
codeCount++;
continue;
}
let beautified = valueSrc;
try {
beautified = await prettier.format('(' + valueSrc + ')', {
parser: 'babel',
semi: true,
singleQuote: false,
printWidth: 110,
tabWidth: 2,
});
beautified = beautified.trim();
if (beautified.startsWith('(') && beautified.endsWith(')')) {
beautified = beautified.slice(1, -1).trim();
}
while (beautified.endsWith(';')) beautified = beautified.slice(0, -1).trimEnd();
} catch (e) {
failCount++;
}
const filePath = path.join(UNPACKED_DIR, `module_${paddedId}.js`);
const fileContent =
`// ============================================================\n` +
`// Module ID: ${keyRaw}\n` +
`// ============================================================\n` +
`;(window.__modules__ = window.__modules__ || {})[${idLiteral}] = ${beautified};\n`;
fs.writeFileSync(filePath, fileContent, 'utf8');
codeModuleIds.push(keyNum);
codeCount++;
if (idx % 200 === 0) {
console.log(` 已处理 ${idx}/${props.length} (资源 ${resourceCount}, 代码 ${codeCount})`);
}
}
console.log(` 完成。资源模块: ${resourceCount}, 代码模块: ${codeCount}, 美化失败: ${failCount}`);
console.log('[4/7] 写入 resources.json...');
if (LOADER_ONLY && fs.existsSync(RESOURCES_JSON_PATH)) {
console.log(' --loader-only:复用已有 resources.json');
} else {
fs.writeFileSync(
RESOURCES_JSON_PATH,
JSON.stringify(
{
type: 'webpack-resource-map',
generatedAt: new Date().toISOString(),
totalResources: resourceCount,
resourceMap,
},
null,
0
),
'utf8'
);
}
console.log(` resources.json 大小: ${(fs.statSync(RESOURCES_JSON_PATH).size / 1024).toFixed(1)} KB`);
console.log('[5/7] 提取原 webpack runtime...');
const runtimeStart = objEnd;
const returnIdx = source.lastIndexOf('return __webpack_exports__');
if (returnIdx === -1) throw new Error('找不到 return __webpack_exports__');
const returnEnd = returnIdx + 'return __webpack_exports__'.length;
let runtimeCode = source.slice(runtimeStart, returnEnd);
if (runtimeCode.startsWith(',')) {
runtimeCode = ';var ' + runtimeCode.slice(1);
}
console.log(` runtime 代码长度: ${runtimeCode.length} 字符`);
console.log(`[6/7] 生成主文件 ${path.basename(BUNDLE_PATH)}...`);
const codeIdsArrayLiteral = codeModuleIds
.map((id) => (typeof id === 'number' ? id : JSON.stringify(id)))
.join(',');
const loaderCode = `
// ===== 切割版模块加载器 =====
window.__modules__ = {};
const __base_url__ = window.__script_base_url__ || './';
// 6.0 单包(unpacked.pack.json):一次请求拿到 resources.json + 全部代码模块,
// 避免 126 个小文件碎请求;没有包时自动回退逐文件加载(见 6.2)
const __pack_files__ = await (async () => {
try {
const __pack_resp__ = await fetch(__base_url__ + 'unpacked.pack.json', { cache: 'no-cache' });
if (!__pack_resp__.ok) {
console.warn('[Hornet] 单包不可用(HTTP ' + __pack_resp__.status + '),回退逐文件加载');
return null;
}
const __pack__ = await __pack_resp__.json();
console.log('[Hornet] 单包已载入: ' + (__pack__.count || 0) + ' 个文件 版本=' + __pack__.version);
return __pack__.files || null;
} catch (__pack_err__) {
console.warn('[Hornet] 单包不可用,回退逐文件加载: ' + (__pack_err__ && __pack_err__.message));
return null;
}
})();
// 6.1 加载资源模块 (resources.json;优先从单包里取)
let __resource_data__ = null;
if (__pack_files__ && typeof __pack_files__['resources.json'] === 'string') {
__resource_data__ = JSON.parse(__pack_files__['resources.json']);
} else {
const __resource_resp__ = await fetch(__base_url__ + 'unpacked/resources.json');
if (!__resource_resp__.ok) throw new Error('resources.json load failed: HTTP ' + __resource_resp__.status);
__resource_data__ = await __resource_resp__.json();
}
const __resource_map__ = __resource_data__.resourceMap;
for (const __id__ in __resource_map__) {
if (!Object.prototype.hasOwnProperty.call(__resource_map__, __id__)) continue;
const __path__ = __resource_map__[__id__];
const __id_num__ = Number(__id__);
window.__modules__[__id_num__] = (function(__p__) {
return (t, n, i) => {
"use strict";
t.exports = i.p + __p__;
};
})(__path__);
}
// 6.2 加载代码模块:单包命中直接 eval(无网络请求);缺的/没包时回退逐文件 fetch
const __code_ids__ = [${codeIdsArrayLiteral}];
const __mod_name__ = (__id__) => 'module_' + (typeof __id__ === 'number' ? String(__id__).padStart(5, '0') : __id__) + '.js';
const __missing__ = [];
for (const __id__ of __code_ids__) {
const __name__ = __mod_name__(__id__);
const __src__ = __pack_files__ ? __pack_files__[__name__] : null;
if (typeof __src__ === 'string') (0, eval)(__src__);
else __missing__.push(__id__);
}
if (__missing__.length) {
if (__pack_files__) console.warn('[Hornet] 单包缺少 ' + __missing__.length + ' 个模块,回退逐文件加载');
await Promise.all(__missing__.map(async (__id__) => {
const __url__ = __base_url__ + 'unpacked/' + __mod_name__(__id__);
const __resp__ = await fetch(__url__);
if (!__resp__.ok) throw new Error('Module ' + __id__ + ' load failed: HTTP ' + __resp__.status + ' @ ' + __url__);
(0, eval)(await __resp__.text());
}));
}
var __webpack_modules__ = window.__modules__;
`;
// 6.3 hornet 工具链自动加载(注入在 runtime 入口模块加载之后、return 之前)
const hornetCode = WITH_HORNET
? `
// ===== 暴露 webpack require(hornet 工具链 hook_gamemode/hornet_lib 依赖)=====
window.__wp_require__ = __webpack_require__;
// ===== 出错时不自动刷新页面 =====
// 游戏在致命错误/断线后会调用 location.reload(),err toast 一闪就被刷掉、看不到原因。
// 拦截自动刷新并保留页面:① 接管 location.reload;② 不可写时用 beforeunload 守卫兜底。
// 需要刷新时按 F5:先放行一次守卫,再用原始 reload 真正刷新。
var __hornetRealReload__ = window.location.reload.bind(window.location);
var __reloadHijacked__ = false;
try {
window.location.reload = function () {
console.warn('[Hornet] 已拦截自动刷新(保留页面,便于查看 err toast)。调用栈:\n' + new Error().stack);
};
__reloadHijacked__ = (window.location.reload !== __hornetRealReload__);
} catch (__reloadErr__) { __reloadHijacked__ = false; }
window.addEventListener('beforeunload', function (e) {
if (window.__hornetAllowUnload__) { window.__hornetAllowUnload__ = false; return; }
if (__reloadHijacked__) return;
e.preventDefault();
e.returnValue = '';
});
console.log('[Hornet] 自动刷新拦截: ' + (__reloadHijacked__ ? 'location.reload 已接管' : 'beforeunload 守卫'));
// ===== F5 刷新页面(游戏会吞掉 F5,这里在捕获阶段兜底)=====
window.addEventListener('keydown', function (e) {
if (e.key === 'F5') {
e.preventDefault();
e.stopImmediatePropagation();
window.__hornetAllowUnload__ = true;
__hornetRealReload__();
}
}, true);
// ===== 加载 hornet_bundle(默认加载;纯净页面用 #nohornet)=====
// 开关必须用 hash,不能用查询串:游戏在 module_13431 里把 location.search 的所有查询参数
// 注册成 config 参数,于是 ?hornet=1 会让 Hash Request 多出资源名 "hornet",真实服务器直接关闭连接(1005)。
if (/[?&]hornet=1(&|$)/.test(location.search)) {
console.warn('[Hornet] 请勿使用 ?hornet=1:查询参数会被游戏当成 config 参数,导致上游握手失败(1005)');
}
if (!/#nohornet/.test(location.hash) && !window.__hornetTools__) {
try {
const __hornet_url__ = __base_url__ + 'unpacked/hornet_bundle.js';
const __hornet_resp__ = await fetch(__hornet_url__);
if (__hornet_resp__.ok) {
const __hornet_code__ = await __hornet_resp__.text();
(0, eval)(__hornet_code__);
console.log('[Hornet] bundle 已加载');
} else {
console.warn('[Hornet] bundle 加载失败: HTTP ' + __hornet_resp__.status);
}
} catch (__hornet_err__) {
console.error('[Hornet] bundle 加载异常:', __hornet_err__);
}
}
// ===== 注册 Service Worker(仅本机镜像)
// 作用域必须是 /:首页 / 发起的 /play/... 请求才会被接管(serve.js 放行 Service-Worker-Allowed)
// 用途:把 unpacked 下的散装小文件用单包就地命中(hornet_sw.js 内置单包缓存)
if ('serviceWorker' in navigator && /^(localhost|127\\.0\\.0\\.1|\\[::1\\])$/.test(location.hostname)) {
navigator.serviceWorker
.register('/hornet_sw.js', { scope: '/', updateViaCache: 'none' })
.then(function (Reg) {
console.log('[Hornet] Service Worker 就绪(作用域 ' + Reg.scope + '),unpacked 小文件将由单包命中');
return Reg.update();
})
.catch(function (Err) {
console.warn('[Hornet] Service Worker 注册失败: ' + (Err && Err.message));
});
}
`
: '';
let runtimeWithHornet = runtimeCode;
if (WITH_HORNET) {
const retIdx = runtimeWithHornet.lastIndexOf('return __webpack_exports__');
if (retIdx >= 0) {
runtimeWithHornet = runtimeWithHornet.slice(0, retIdx) + hornetCode + runtimeWithHornet.slice(retIdx);
} else {
console.warn(' [WARN] 未找到 return __webpack_exports__,跳过 hornet 注入');
}
}
const newBundle =
UMD_PREFIX +
'\n' +
loaderCode +
'\n// ===== 原 webpack runtime (一字未改) =====\n' +
runtimeWithHornet +
';\n' +
UMD_SUFFIX(BUNDLE_PATH);
fs.writeFileSync(MAIN_PATH, newBundle, 'utf8');
console.log(` 主文件大小: ${(newBundle.length / 1024).toFixed(1)} KB`);
console.log('[7/7] 验证语法...');
try {
acorn.parse(newBundle, {
ecmaVersion: 'latest',
sourceType: 'script',
allowReturnOutsideFunction: true,
});
console.log(' ✓ 主文件语法正确');
} catch (e) {
console.error(` ✗ 语法错误: ${e.message}`);
process.exit(1);
}
console.log('\n=== 完成 ===');
console.log(` 总模块数: ${props.length}`);
console.log(` 资源模块: ${resourceCount} (存入 resources.json)`);
console.log(` 代码模块: ${codeCount} (独立文件)`);
console.log(` 美化失败: ${failCount}`);
console.log(` 主文件: ${MAIN_PATH}`);
console.log(` 资源 JSON: ${RESOURCES_JSON_PATH}`);
console.log(` 代码模块目录: ${UNPACKED_DIR}\\`);
})().catch((err) => {
console.error('Fatal:', err);
process.exit(1);
});