feedback5 R1: pure move — the top-level code of server/index.js goes to server/http/ (config, common, files, static, media, buildTag, websocket, boot) verbatim; index.js imports it and re-exports the same 15 names, startServer and main are untouched
Only changes besides the move: `export` on helpers another module now needs (sendError, sendJson, splitUrl, GzipCache, isNotModified, serveFile, serveMedia, noopLog, makeLogger), ROOT resolves two levels up from server/http/config.js (same path), and short headers on the new files.
This commit is contained in:
1 parent
6b489b1776
commit
b48a67caec
9 files changed
+619
-552
No files matched your search
@@ -0,0 +1,137 @@
|
||||
// server/http/static.js — the static file server (moved from server/index.js):
|
||||
// * node:http static server: / → public/ (index.html for directories)
|
||||
// /data/ → data/ (generated game data)
|
||||
// /shared/ → shared/ (ESM shared with the browser)
|
||||
// /sim/ → server/sim/ (the battle simulation, read-only, `.js` only — client-side
|
||||
// combat, DESIGN §14; the Node-only loader nodeData.js is not served)
|
||||
// /data.js → a generated browser stand-in of server/data.js (the sim's content modules
|
||||
// import `../../../data.js`; in the browser it serves the data injected with
|
||||
// /sim/simdata.js setSimData). No other server file is ever served.
|
||||
// /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 — the same audio files, addressed
|
||||
// **without** an extension so download managers (IDM / 迅雷 …) stop popping a
|
||||
// "下载文件信息" dialog for every BGM track (shared/media.js, public/js/media.js)
|
||||
// traversal & dotfile protection; 404 page. Files go out through files.js serveFile.
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { MEDIA_PREFIX } from '../../shared/media.js';
|
||||
import { ROOT, noopLog } from './config.js';
|
||||
import { sendError } from './common.js';
|
||||
import { MIME, GzipCache, isNotModified, serveFile } from './files.js';
|
||||
import { serveMedia } from './media.js';
|
||||
|
||||
/** Browser stand-in of server/data.js, served at /data.js (see the header). */
|
||||
export const DATA_SHIM_JS = `// Generated by server/index.js — browser stand-in for server/data.js (DESIGN §14 client-side combat).
|
||||
// The simulation's content modules (/sim/content/support/index.js) import getData() from here; it returns the game data
|
||||
// the page injected with /sim/simdata.js setSimData(data).
|
||||
import { getSimData } from './sim/simdata.js';
|
||||
export function getData() { return getSimData() || {}; }
|
||||
export function resetData() {}
|
||||
`;
|
||||
/** Files under server/sim that are never served (Node-only). */
|
||||
const SIM_PRIVATE = new Set(['nodedata.js']); // lower-case (compared case-insensitively)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Static file handler
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create the static request handler.
|
||||
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, log?: object }} dirs
|
||||
* @returns {(req: http.IncomingMessage, res: http.ServerResponse, rawPath: string, query: string) => Promise<void>}
|
||||
*/
|
||||
/** Optional per-machine art manifest (tools/local-extract) and the empty stand-in served when it is absent. */
|
||||
const LOCAL_ART_MANIFEST = 'local-assets.json';
|
||||
const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none', count: 0, groups: {} }));
|
||||
|
||||
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), log = noopLog }) {
|
||||
const mounts = [
|
||||
{ prefix: '/data/', name: 'data', dir: path.resolve(dataDir) },
|
||||
{ prefix: '/shared/', name: 'shared', dir: path.resolve(sharedDir) },
|
||||
// the simulation: ES modules only (no directory listings, no other file types, no Node-only loader)
|
||||
{ prefix: '/sim/', name: 'sim', dir: path.resolve(simDir), only: new Set(['.js']), deny: SIM_PRIVATE },
|
||||
{ prefix: '/', name: 'public', dir: path.resolve(publicDir) },
|
||||
];
|
||||
const shimBody = Buffer.from(DATA_SHIM_JS);
|
||||
const shimTag = `"shim-${shimBody.length.toString(16)}"`;
|
||||
const gzipCache = new GzipCache();
|
||||
|
||||
return async function serveStatic(req, res, rawPath, query) {
|
||||
let decoded;
|
||||
try { decoded = decodeURIComponent(rawPath); } catch { sendError(req, res, 400, '请求地址无效 · Bad request'); return; }
|
||||
if (!decoded.startsWith('/') || decoded.includes('\0') || decoded.includes('\\')) {
|
||||
sendError(req, res, 400, '请求地址无效 · Bad request');
|
||||
return;
|
||||
}
|
||||
if (decoded === '/data.js') {
|
||||
const headers = { 'Content-Type': MIME['.js'], 'Cache-Control': 'no-cache', ETag: shimTag, 'Content-Length': shimBody.length };
|
||||
if (isNotModified(req, shimTag, new Date(0))) { delete headers['Content-Length']; res.writeHead(304, headers); res.end(); return; }
|
||||
res.writeHead(200, headers);
|
||||
res.end(req.method === 'HEAD' ? undefined : shimBody);
|
||||
return;
|
||||
}
|
||||
// Extension-less audio (download-manager avoidance): /media/bgm/act1 → /assets/audio/bgm/act1.mp3
|
||||
if (decoded.startsWith(MEDIA_PREFIX)) {
|
||||
await serveMedia(req, res, decoded.slice(MEDIA_PREFIX.length), query, publicDir, gzipCache, log);
|
||||
return;
|
||||
}
|
||||
// Bare mount paths (e.g. "/data") → treat as the mount directory.
|
||||
const mount = mounts.find((m) => decoded.startsWith(m.prefix) || decoded === m.prefix.slice(0, -1)) || mounts[mounts.length - 1];
|
||||
const rest = decoded.length > mount.prefix.length ? decoded.slice(mount.prefix.length) : '';
|
||||
const segments = rest.split('/').filter((s) => s.length > 0);
|
||||
if (segments.some((s) => s === '..' || s === '.')) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
|
||||
if (segments.some((s) => s.startsWith('.'))) { sendError(req, res, 404, '页面不存在 · Not found'); return; }
|
||||
if (mount.only && (!segments.length || !mount.only.has(path.extname(segments[segments.length - 1]).toLowerCase())
|
||||
// (case-insensitive: the host may be Windows / macOS, where NODEDATA.JS opens nodeData.js)
|
||||
|| (mount.deny && mount.deny.has(segments[segments.length - 1].toLowerCase())))) {
|
||||
sendError(req, res, 404, '页面不存在 · Not found');
|
||||
return;
|
||||
}
|
||||
let absPath = path.join(mount.dir, ...segments);
|
||||
if (absPath !== mount.dir && !absPath.startsWith(mount.dir + path.sep)) { sendError(req, res, 403, '禁止访问 · Forbidden'); return; }
|
||||
|
||||
let stat;
|
||||
let viaDirectory = false;
|
||||
try {
|
||||
stat = await fsp.stat(absPath);
|
||||
if (stat.isDirectory()) {
|
||||
if (!decoded.endsWith('/')) {
|
||||
// Built from normalized segments (never from the raw path) so "//host" can't become an open redirect.
|
||||
const loc = (mount.prefix + segments.map(encodeURIComponent).join('/') + '/').replace(/\/{2,}/g, '/');
|
||||
res.writeHead(301, { Location: loc + (query ? `?${query}` : ''), 'Cache-Control': 'no-cache', 'Content-Length': 0 });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
absPath = path.join(absPath, 'index.html');
|
||||
segments.push('index.html');
|
||||
viaDirectory = true;
|
||||
stat = await fsp.stat(absPath);
|
||||
}
|
||||
// Not a regular file, or a file addressed like a directory ("/app.js/") → 404.
|
||||
if (!stat.isFile() || (decoded.endsWith('/') && !viaDirectory)) {
|
||||
throw Object.assign(new Error('not a file'), { code: 'ENOENT' });
|
||||
}
|
||||
if (mount.deny) {
|
||||
// the on-disk name decides (case-insensitive file systems, Windows 8.3 short names like NODEDA~1.JS)
|
||||
const real = await fsp.realpath(absPath);
|
||||
if (mount.deny.has(path.basename(real).toLowerCase())) throw Object.assign(new Error('private'), { code: 'ENOENT' });
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ENOENT' && mount.name === 'data' && segments.length === 1 && segments[0] === LOCAL_ART_MANIFEST) {
|
||||
// Optional local-client art (DESIGN §13): an install without it gets an empty manifest instead of a 404,
|
||||
// so browsers don't log an error on every page load. Clients treat empty groups as "no local art".
|
||||
res.writeHead(200, { 'Content-Type': MIME['.json'], 'Cache-Control': 'no-cache', 'Content-Length': EMPTY_LOCAL_ART.length });
|
||||
res.end(req.method === 'HEAD' ? undefined : EMPTY_LOCAL_ART);
|
||||
} else if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR' || e.code === 'EISDIR' || e.code === 'ENAMETOOLONG')) {
|
||||
sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : '');
|
||||
} else if (e && (e.code === 'EACCES' || e.code === 'EPERM')) {
|
||||
sendError(req, res, 403, '禁止访问 · Forbidden');
|
||||
} else {
|
||||
log.error('[http] stat failed', e);
|
||||
sendError(req, res, 500, '服务器内部错误 · Internal error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
await serveFile(req, res, absPath, stat, mount.name, segments, query, gzipCache, log);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user