Files
TankIC/tools/split.js
T
TermiNexus 469f913619 fix(page): 修复 index.html 主文件引用失配(新主文件没被加载,自动刷新拦截等全部失效)
- 根因:index.html 写死 main.9f4e7a89.js,重拆后主文件改名 main.7cfa4058.js → 请求旧名 404,新主文件里的 HORNET 注入 / 自动刷新拦截 / F5 兜底压根没加载

- 3DTank/web/index.html:引用改为 main.7cfa4058.js

- tools/serve.js:Resolve 增加兜底 —— 请求 main.<hash>.js 但文件不存在时,回退到目录里最新的 main.<hash>.js(以后换 bundle 无需再改 HTML)

- tools/split.js:拆分完成后自动改写 index.html 的主文件引用(防复发)
2026-10-16 14:23:02 +08:00

439 lines
18 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__;
// ===== 出错时不自动刷新页面(**默认开启**;localStorage.hornetBlockReload='0' 关闭)=====
// 游戏在致命错误/断线后会调用 location.reload(),err toast 一闪就被刷掉、看不到原因。
// 拦截自动刷新并保留页面:① 接管 location.reload;② 不可写时用 beforeunload 守卫兜底。
// 需要刷新时按 F5:先放行一次守卫,再用原始 reload 真正刷新。
// ⚠️ 拦截期间游戏无法靠刷页重连,只能反复内部重连(临时排查用;稳定后设 hornetBlockReload='0')。
var __hornetRealReload__ = window.location.reload.bind(window.location);
var __reloadHijacked__ = false;
if (localStorage.getItem('hornetBlockReload') !== '0') {
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__ ? '已启用(会阻断断线重连)' : (localStorage.getItem('hornetBlockReload') === '0' ? '已关闭(hornetBlockReload=0)' : '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:已从模板移除(排查断联用,避免单包缓存接管干扰)=====
// 需要恢复时见 git 历史;hornet_sw.js 本体与 serve.js 的 /hornet_sw.js 路由保留未删。
`
: '';
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}`);
// ===== 自动同步 index.html 里的主文件引用 =====
// 主文件名带 bundle hash(main.<hash>.js),而 index.html 里写的是固定名;
// 重新拆分后必须一起改,否则页面请求旧名 404 → 新主文件(含 HORNET 注入/自动刷新拦截)全部失效。
try {
const HTML_PATH = path.resolve(__dirname, '..', '3DTank', 'web', 'index.html');
if (fs.existsSync(HTML_PATH)) {
const MainName = path.basename(MAIN_PATH);
const Html = fs.readFileSync(HTML_PATH, 'utf8');
const NewHtml = Html.replace(/\/play\/static\/js\/main\.[0-9a-f]{8}\.js/g, '/play/static/js/' + MainName);
if (NewHtml !== Html) {
fs.writeFileSync(HTML_PATH, NewHtml, 'utf8');
console.log(` index.html: 已更新主文件引用 → ${MainName}`);
} else {
console.log(` index.html: 引用已是最新 (${MainName})`);
}
}
} catch (e) {
console.log(' index.html: 同步失败(不影响拆分结果): ' + e.message);
}
console.log(` 主文件: ${MAIN_PATH}`);
console.log(` 资源 JSON: ${RESOURCES_JSON_PATH}`);
console.log(` 代码模块目录: ${UNPACKED_DIR}\\`);
})().catch((err) => {
console.error('Fatal:', err);
process.exit(1);
});