- 根因: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 的主文件引用(防复发)
439 lines
18 KiB
JavaScript
439 lines
18 KiB
JavaScript
// 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);
|
||
});
|