Files
MatchMaster/tools/serve-activity-resources.cjs
T
2026-10-12 15:00:49 +08:00

75 lines
3.8 KiB
JavaScript

'use strict';
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const resourceRoot = path.resolve(__dirname, '../local-activity-resources/remote');
function createServer(root = resourceRoot) {
fs.mkdirSync(root, { recursive: true });
const realRoot = fs.realpathSync(root);
const inside = file => {
const relative = path.relative(realRoot, file);
return relative && relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative);
};
return http.createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
const reply = (code, message) => {
res.writeHead(code, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(req.method === 'HEAD' ? undefined : message);
};
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
if (!['GET', 'HEAD'].includes(req.method)) {
res.setHeader('Allow', 'GET, HEAD, OPTIONS'); reply(405, 'Method not allowed'); return;
}
let pathname;
try { pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname); }
catch (_) { reply(400, 'Invalid URL'); return; }
if (pathname === '/') {
reply(200, 'Activity resource server\nCopy manifest.json and bundle folders into:\n' + realRoot + '\n\nManifest: /remote/manifest.json\n'); return;
}
if (!pathname.startsWith('/remote/') || pathname.includes('\\') || pathname.includes('\0')) {
reply(404, 'Not found'); return;
}
const relative = pathname.slice('/remote/'.length);
if (relative.split('/').some(part => !part || part === '.' || part === '..') || !/\.(json|zip)$/.test(relative)) {
reply(404, 'Not found'); return;
}
try {
const file = await fs.promises.realpath(path.resolve(realRoot, relative));
if (!inside(file)) { reply(404, 'Not found'); return; }
const stat = await fs.promises.stat(file);
if (!stat.isFile()) { reply(404, 'Not found'); return; }
res.setHeader('Content-Type', file.endsWith('.zip') ? 'application/zip' : 'application/json; charset=utf-8');
res.setHeader('Content-Length', stat.size);
if (req.method === 'HEAD') { res.end(); return; }
const stream = fs.createReadStream(file);
stream.on('error', () => {
if (res.headersSent) res.destroy();
else { res.removeHeader('Content-Length'); reply(500, 'Resource read failed'); }
});
res.on('close', () => stream.destroy());
stream.pipe(res);
} catch (error) {
reply(error.code === 'ENOENT' || error.code === 'ENOTDIR' ? 404 : 500, 'Resource unavailable');
}
});
}
module.exports = { createServer, resourceRoot };
if (require.main === module) {
const port = Number(process.argv[2] || 8780);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw Error('Port must be between 1 and 65535');
const server = createServer();
server.on('error', error => { console.error('Activity resource server failed:', error.message); process.exitCode = 1; });
server.listen(port, '127.0.0.1', () => {
console.log('Resource directory: ' + resourceRoot);
console.log('Resource baseUrl: http://127.0.0.1:' + port + '/remote/');
console.log('Manifest: http://127.0.0.1:' + port + '/remote/manifest.json');
console.log('Files are read on every request. Copy new files without restarting. Ctrl+C to stop.');
});
}