// res_proxy.js —— 本地资源代理(res.3dtank.com → 本地缓存) // // 为什么需要:官方 3DS 贴图(BG1 1024.PNG / SKY1.PNG 等)没有 CORS 头, // 浏览器从 localhost 直接请求会被拦截;同时每次开发都走公网很慢。 // // 用法:node tools/res_proxy.js [--port 8082] [--cache build/res-cache] // GET /res/ → https://res.3dtank.com/,落盘缓存后返回 // GET /play/ → 3DTank/web/play/(本地镜像,可选) // GET /health → ok // // 前端通过 webpack devServer 把 /res 代理到本服务(webpack.config.d/res-proxy.js)。 'use strict'; const http = require('http'); const fs = require('fs'); const path = require('path'); const { URL } = require('url'); 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 Root = path.resolve(__dirname, '..'); const Port = Number(Arg('port', '8082')); const CacheDir = path.resolve(Root, Arg('cache', 'build/res-cache')); const MirrorPlay = path.resolve(Root, '..', '3DTank', 'web', 'play'); const Upstream = 'https://res.3dtank.com'; const Mime = { '.3ds': 'application/octet-stream', '.a3d': 'application/octet-stream', '.webp': 'image/webp', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.json': 'application/json', '.info': 'application/octet-stream', '.mp3': 'audio/mpeg', '.ktx': 'application/octet-stream', }; fs.mkdirSync(CacheDir, { recursive: true }); function CachePath(Key) { const Clean = Key.replace(/^\/+/, '').replace(/[^a-zA-Z0-9._/-]/g, '_').replace(/\.\./g, '_'); return path.join(CacheDir, Clean); } function Send(Res, Status, Type, Body) { Res.writeHead(Status, { 'Content-Type': Type || 'application/octet-stream', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': '*', 'Cache-Control': 'public, max-age=31536000, immutable', }); Res.end(Body); } async function FetchUpstream(PathAndQuery) { const Decoded = decodeURIComponent(PathAndQuery); const Ext = path.extname(Decoded); const NoExt = Decoded.slice(0, Decoded.length - Ext.length); const Candidates = [ PathAndQuery, Decoded.toLowerCase(), (NoExt + '.webp').toLowerCase(), (NoExt + '.png').toLowerCase(), (NoExt + '.webp'), (NoExt + '.png'), ]; for (const PathTry of [...new Set(Candidates)]) { const R = await fetch(Upstream + (PathTry.startsWith('/') ? PathTry : '/' + PathTry), { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) TankiOffline/0.1', Accept: '*/*', }, }); if (R.ok) { const Body = Buffer.from(await R.arrayBuffer()); return { Status: 200, Type: R.headers.get('content-type') || 'application/octet-stream', Body, Resolved: PathTry, }; } } return { Status: 404, Type: 'text/plain', Body: Buffer.from('upstream 404') }; } const Server = http.createServer(async (Req, Res) => { if (Req.method === 'OPTIONS') { Send(Res, 204, 'text/plain', Buffer.alloc(0)); return; } const U = new URL(Req.url, 'http://localhost'); if (U.pathname === '/health') { Send(Res, 200, 'text/plain', Buffer.from('ok')); return; } // 本地镜像:/play/... → 3DTank/web/play/... if (U.pathname.startsWith('/play/')) { const File = path.join(MirrorPlay, U.pathname.slice('/play/'.length)); if (File.startsWith(MirrorPlay) && fs.existsSync(File) && fs.statSync(File).isFile()) { Send(Res, 200, Mime[path.extname(File).toLowerCase()], fs.readFileSync(File)); return; } Send(Res, 404, 'text/plain', Buffer.from('mirror miss')); return; } // 线上资源:/res/ → res.3dtank.com/ if (!U.pathname.startsWith('/res/')) { Send(Res, 404, 'text/plain', Buffer.from('not found')); return; } const Key = U.pathname.slice('/res'.length); const Cache = CachePath(Key); if (fs.existsSync(Cache) && fs.statSync(Cache).isFile()) { Send(Res, 200, Mime[path.extname(Cache).toLowerCase()] || 'application/octet-stream', fs.readFileSync(Cache)); return; } try { const Result = await FetchUpstream(Key); if (Result.Status === 200) { fs.mkdirSync(path.dirname(Cache), { recursive: true }); fs.writeFileSync(Cache, Result.Body); } Send(Res, Result.Status, Result.Type, Result.Body); console.log(`${Result.Status === 200 ? 'cache' : 'fail '} ${Key} (${Result.Body.length}B)`); } catch (E) { Send(Res, 502, 'text/plain', Buffer.from('proxy error: ' + E.message)); console.log('error', Key, E.message); } }); Server.listen(Port, '127.0.0.1', () => { console.log(`res proxy: http://127.0.0.1:${Port}/res/... (cache: ${CacheDir})`); console.log(`play mirror: http://127.0.0.1:${Port}/play/... (${MirrorPlay})`); });