feedback5 WAD: language packs are the first type of content packs — a language is a file in a language folder (public/i18n/<code>.json, optional data/i18n/<code>.json) or a pack folder (packs/<id>/pack.json); the server lists them live at /packs/index.json (server/packs.js), the menu shows every pack, strings fall back pack → base → declared fallback (English) → Chinese, broken placeholders are skipped, plurals follow Intl.PluralRules (English unchanged), English carries a _meta manifest like any pack; tools: i18n.mjs template / check <code>, packs.mjs list / index, build-i18n --lang ja|ko|zh-TW (the official JP / KR / TW clients); the release zip ships packs/ and a generated packs/index.json; golden unchanged
This commit is contained in:
1 parent
f1df0d359d
commit
25b2a87b26
22 files changed
+2222
-241
No files matched your search
@@ -4,8 +4,8 @@
|
||||
// * TRUST_PROXY ('auto' default: honour CF-Connecting-IP / X-Real-IP / X-Forwarded-For only from loopback/private
|
||||
// peers such as a local cloudflared; '1' always; '0' never) → net.js trustProxy;
|
||||
// * DEBUG → the console logger's debug level;
|
||||
// * the served directories (public/, data/, shared/ of this repository unless the options name others), and which
|
||||
// startServer() options are handed on to net.js Network and lobby.js Lobby.
|
||||
// * the served directories (public/, data/, shared/ and the content packs' packs/ of this repository unless the
|
||||
// options name others), and which startServer() options are handed on to net.js Network and lobby.js Lobby.
|
||||
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -34,14 +34,15 @@ export function listenAddress(opts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The directories the static server reads.
|
||||
* @param {{ publicDir?: string, dataDir?: string, sharedDir?: string }} opts
|
||||
* The directories the static server reads (packsDir: the pack folders, server/packs.js).
|
||||
* @param {{ publicDir?: string, dataDir?: string, sharedDir?: string, packsDir?: string }} opts
|
||||
*/
|
||||
export function serveDirs(opts) {
|
||||
return {
|
||||
publicDir: opts.publicDir || path.join(ROOT, 'public'),
|
||||
dataDir: opts.dataDir || path.join(ROOT, 'data'),
|
||||
sharedDir: opts.sharedDir || path.join(ROOT, 'shared'),
|
||||
packsDir: opts.packsDir || path.join(ROOT, 'packs'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+21
-3
@@ -12,6 +12,9 @@
|
||||
// /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 (media.js, shared/media.js, public/js/media.js)
|
||||
// /packs/index.json → the content packs of this server (server/packs.js: the language packs of
|
||||
// public/i18n/ and the pack folders of packs/, re-read when they change; never cached)
|
||||
// /packs/<id>/<file> → a file of a pack folder — only one its manifest names (server/packs.js servable)
|
||||
//
|
||||
// Traversal & dotfile protection, a directory without its trailing slash → 301, 404 page; an absent
|
||||
// data/local-assets.json is answered with an empty manifest. Files go out through files.js serveFile.
|
||||
@@ -20,9 +23,11 @@ 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 { sendError, sendJson } from './common.js';
|
||||
import { MIME, GzipCache, isNotModified, serveFile } from './files.js';
|
||||
import { serveMedia } from './media.js';
|
||||
import { createPackRegistry } from '../packs.js';
|
||||
import { PACKS_URL, PACK_INDEX_FILE } from '../../shared/packs.js';
|
||||
|
||||
/** Browser stand-in of server/data.js, served at /data.js (see the header). Served byte for byte: its first line names the entry, server/index.js. */
|
||||
export const DATA_SHIM_JS = `// Generated by server/index.js — browser stand-in for server/data.js (DESIGN §14 client-side combat).
|
||||
@@ -45,10 +50,13 @@ const EMPTY_LOCAL_ART = Buffer.from(JSON.stringify({ version: 1, source: 'none',
|
||||
|
||||
/**
|
||||
* Create the static request handler.
|
||||
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, log?: object }} dirs
|
||||
* @param {{ publicDir: string, dataDir: string, sharedDir: string, simDir?: string, packsDir?: string,
|
||||
* packs?: ReturnType<typeof createPackRegistry>, log?: object }} dirs
|
||||
* packs: the server's pack registry (default: one over publicDir, dataDir and packsDir — ROOT/packs)
|
||||
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, rawPath: string, query: string) => Promise<void>}
|
||||
*/
|
||||
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), log = noopLog }) {
|
||||
export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = path.join(ROOT, 'server', 'sim'), packsDir = path.join(ROOT, 'packs'), packs = null, log = noopLog }) {
|
||||
const registry = packs || createPackRegistry({ publicDir, dataDir, packsDir }, { log: /** @type {any} */ (log) });
|
||||
const mounts = [
|
||||
{ prefix: '/data/', name: 'data', dir: path.resolve(dataDir) },
|
||||
{ prefix: '/shared/', name: 'shared', dir: path.resolve(sharedDir) },
|
||||
@@ -74,6 +82,16 @@ export function createStaticHandler({ publicDir, dataDir, sharedDir, simDir = pa
|
||||
res.end(req.method === 'HEAD' ? undefined : shimBody);
|
||||
return;
|
||||
}
|
||||
// Content packs (server/packs.js): the live index, and only the files a pack's manifest names
|
||||
if (decoded.startsWith(PACKS_URL)) {
|
||||
const parts = decoded.slice(PACKS_URL.length).split('/');
|
||||
if (parts.length === 1 && parts[0] === PACK_INDEX_FILE) { sendJson(req, res, 200, registry.index()); return; }
|
||||
const abs = parts.length >= 2 && !parts.some((s) => s === '' || s === '.' || s === '..') ? registry.servable(parts[0], parts.slice(1).join('/')) : null;
|
||||
const st = abs ? await fsp.stat(abs).catch(() => null) : null;
|
||||
if (!st || !st.isFile()) { sendError(req, res, 404, '页面不存在 · Not found', decoded.length <= 200 ? decoded : ''); return; }
|
||||
await serveFile(req, res, abs, st, 'packs', parts, query, gzipCache, log);
|
||||
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);
|
||||
|
||||
+12
-6
@@ -5,7 +5,8 @@
|
||||
// which startServer() options go to net.js / lobby.js, the console logger
|
||||
// http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB;
|
||||
// refused at upgrade with 404 / 429 per network / 503)
|
||||
// http/static.js the static mounts (/ → public/, /data/, /shared/, /sim/ `.js` only), the /data.js browser stand-in
|
||||
// http/static.js the static mounts (/ → public/, /data/, /shared/, /sim/ `.js` only), the /data.js browser stand-in,
|
||||
// the content packs (/packs/index.json, /packs/<id>/<file> — the registry is packs.js)
|
||||
// http/media.js /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 (audio addressed without its extension)
|
||||
// http/files.js one file → response: MIME, gzip + memory cache, ETag / Last-Modified / 304, Cache-Control, ranges
|
||||
// http/buildTag.js the build tag of the served browser runtime (/healthz `build`, public/js/ui/buildGuard.js)
|
||||
@@ -24,6 +25,7 @@ import { getData, loadData } from './data.js';
|
||||
import { ROOT, listenAddress, serveDirs, makeLogger, parseTrustProxy } from './http/config.js';
|
||||
import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js';
|
||||
import { DATA_SHIM_JS, createStaticHandler } from './http/static.js';
|
||||
import { createPackRegistry } from './packs.js';
|
||||
import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js';
|
||||
import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js';
|
||||
import { createRequestHandler } from './http/routes.js';
|
||||
@@ -40,7 +42,7 @@ export {
|
||||
* Build and start the HTTP + WebSocket server.
|
||||
* @param {{
|
||||
* port?: number, host?: string, quiet?: boolean, log?: object,
|
||||
* publicDir?: string, dataDir?: string, sharedDir?: string,
|
||||
* publicDir?: string, dataDir?: string, sharedDir?: string, packsDir?: string,
|
||||
* MatchClass?: Function, seedFn?: () => number,
|
||||
* lobbyGraceMs?: number, reconnectWindowMs?: number, heartbeatMs?: number, helloTimeoutMs?: number,
|
||||
* ratePerSec?: number, rateBurst?: number, maxConnections?: number, maxRooms?: number,
|
||||
@@ -49,17 +51,21 @@ export {
|
||||
* }} [opts]
|
||||
* @returns {Promise<{ port: number, host: string, url: string, server: http.Server, wss: import('ws').WebSocketServer,
|
||||
* lobby: import('./lobby.js').Lobby, network: import('./net.js').Network,
|
||||
* registry: import('./net.js').SessionRegistry, close: () => Promise<void> }>}
|
||||
* registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>,
|
||||
* close: () => Promise<void> }>}
|
||||
*/
|
||||
export async function startServer(opts = {}) {
|
||||
const { port, host } = listenAddress(opts);
|
||||
const log = opts.log || makeLogger(!!opts.quiet);
|
||||
const { publicDir, dataDir, sharedDir } = serveDirs(opts);
|
||||
const { publicDir, dataDir, sharedDir, packsDir } = serveDirs(opts);
|
||||
|
||||
// The process-wide singleton serves the default data dir; a custom dir (tests) gets its own copy.
|
||||
const data = opts.dataDir ? loadData(dataDir, { log }) : getData({ dir: dataDir, log });
|
||||
const { registry, lobby, network } = createSessionStack(opts, { data, log });
|
||||
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, log });
|
||||
// content packs (docs/PACKS.md): scanned now — the start log names them — and again whenever their folders change
|
||||
const packs = createPackRegistry({ publicDir, dataDir, packsDir }, { log });
|
||||
packs.refresh(true);
|
||||
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log });
|
||||
const startedAt = Date.now();
|
||||
// The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz.
|
||||
resetBuildTag();
|
||||
@@ -103,7 +109,7 @@ export async function startServer(opts = {}) {
|
||||
return closing;
|
||||
}
|
||||
|
||||
return { port: actualPort, host, url, server, wss, lobby, network, registry, close };
|
||||
return { port: actualPort, host, url, server, wss, lobby, network, registry, packs, close };
|
||||
}
|
||||
|
||||
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).
|
||||
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
// server/packs.js — this server's content packs (docs/PACKS.md; the format: shared/packs.js). Discovers
|
||||
//
|
||||
// * folder packs: packs/<id>/pack.json + the files its manifest names (any type);
|
||||
// * single-file language packs: public/i18n/<code>.json (manifest: its `_meta`) + data/i18n/<code>.json when present;
|
||||
//
|
||||
// validates them, and answers the pack index (GET /packs/index.json, server/http/static.js) and which pack files may be
|
||||
// served (GET /packs/<id>/<file>: only a file that the manifest of a supported folder pack names, with its role's
|
||||
// extension, inside the pack folder — nothing else there, ever; a single-file pack's files are the language folders' own,
|
||||
// /i18n/ and /data/i18n/). The folders are re-read when something in them changes (checked when the index or a pack
|
||||
// file is asked for, at most once a second: a few stat calls), so a language pack dropped in shows on the next page
|
||||
// load — no restart, no build step. A pack of a planned type, a broken manifest, a second pack with the same id or
|
||||
// language: reported (`skipped`: the start log, `node tools/packs.mjs list`), never served.
|
||||
//
|
||||
// Extension point (docs/PACKS.md "Adding a pack type"): a new type gets its PACK_TYPES entry (shared/packs.js) and a
|
||||
// loader that reads `list(type)` — a client-side type through the index, a server-side one (type "data") once at start
|
||||
// (`live: false`: the simulation's data is fixed for the life of the process).
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { APP_VERSION } from '../shared/constants.js';
|
||||
import { PACK_TYPES, PACK_MANIFEST, PACKS_URL, normalizeManifest, packIndexEntry, buildPackIndex } from '../shared/packs.js';
|
||||
import { SOURCE_LANG, isLangCode, countStrings } from '../shared/i18nPacks.js';
|
||||
|
||||
/** The language folders' subdirectory (public/i18n/, data/i18n/). */
|
||||
export const LANG_DIR = 'i18n';
|
||||
/** Largest manifest / UI file read (bytes): a pack file is small text. */
|
||||
const MAX_JSON_BYTES = 8 * 1024 * 1024;
|
||||
const noop = () => {};
|
||||
|
||||
/**
|
||||
* @typedef {{ rel: string, abs: string, url: string }} PackFile
|
||||
* @typedef {{ id: string, type: string, layout: 'file' | 'folder', where: string, supported: boolean,
|
||||
* manifest: import('../shared/packs.js').PackManifest, files: Record<string, PackFile>, strings?: number }} Pack
|
||||
* @typedef {{ where: string, problems: string[] }} Skipped
|
||||
*/
|
||||
|
||||
const isFile = (abs) => { try { return fs.statSync(abs).isFile(); } catch { return false; } };
|
||||
const readDirSorted = (dir) => { try { return fs.readdirSync(dir).filter((n) => !n.startsWith('.')).sort(); } catch { return []; } };
|
||||
|
||||
/** A JSON file of at most MAX_JSON_BYTES, or { error }. */
|
||||
function readJson(abs) {
|
||||
try {
|
||||
const st = fs.statSync(abs);
|
||||
if (!st.isFile()) return { error: 'not a file' };
|
||||
if (st.size > MAX_JSON_BYTES) return { error: `larger than ${MAX_JSON_BYTES >> 20} MB` };
|
||||
return { json: JSON.parse(fs.readFileSync(abs, 'utf8')) };
|
||||
} catch (e) {
|
||||
return { error: e instanceof SyntaxError ? `not valid JSON (${e.message})` : String(e?.code || e?.message || e) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether `abs` is inside `dir` once symbolic links are resolved (a pack file never leads out of its folder). */
|
||||
function inside(dir, abs) {
|
||||
try {
|
||||
const d = fs.realpathSync(dir);
|
||||
const f = fs.realpathSync(abs);
|
||||
return f.startsWith(d + path.sep);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
const fileUrl = (id, rel) => `${PACKS_URL}${encodeURIComponent(id)}/${rel.split('/').map(encodeURIComponent).join('/')}`;
|
||||
|
||||
/**
|
||||
* Scan the pack folders once.
|
||||
* @param {{ publicDir: string, dataDir: string, packsDir: string }} dirs
|
||||
* @param {{ app?: string }} [opts]
|
||||
* @returns {{ packs: Pack[], skipped: Skipped[], warnings: { where: string, warning: string }[] }}
|
||||
*/
|
||||
export function scanPacks({ publicDir, dataDir, packsDir }, { app = APP_VERSION } = {}) {
|
||||
/** @type {Pack[]} */
|
||||
const found = [];
|
||||
/** @type {Skipped[]} */
|
||||
const skipped = [];
|
||||
const warnings = [];
|
||||
const warn = (where, list) => { for (const w of list) warnings.push({ where, warning: w }); };
|
||||
|
||||
// single-file language packs: public/i18n/<code>.json
|
||||
const uiDir = path.join(publicDir, LANG_DIR);
|
||||
for (const name of readDirSorted(uiDir)) {
|
||||
if (!name.endsWith('.json')) continue;
|
||||
const code = name.slice(0, -5);
|
||||
const where = `public/${LANG_DIR}/${name}`;
|
||||
if (!isLangCode(code)) { skipped.push({ where, problems: [`"${code}" is not a language code in its usual case (en, ja, zh-TW, pt-BR …)`] }); continue; }
|
||||
if (code === SOURCE_LANG) { skipped.push({ where, problems: ['"zh" is the source language (the msgids), never a pack'] }); continue; }
|
||||
const abs = path.join(uiDir, name);
|
||||
const r = readJson(abs);
|
||||
if (r.error) { skipped.push({ where, problems: [r.error] }); continue; }
|
||||
if (!r.json || typeof r.json !== 'object' || Array.isArray(r.json)) { skipped.push({ where, problems: ['not a JSON object of msgid → translation'] }); continue; }
|
||||
const { manifest, problems, warnings: w } = normalizeManifest(r.json._meta, { id: code, type: 'lang', lang: code, app });
|
||||
warn(where, w);
|
||||
if (!manifest) { skipped.push({ where, problems }); continue; }
|
||||
/** @type {Record<string, PackFile>} */
|
||||
const files = { ui: { rel: name, abs, url: `/${LANG_DIR}/${name}` } };
|
||||
const dataAbs = path.join(dataDir, LANG_DIR, name);
|
||||
if (isFile(dataAbs)) files.data = { rel: name, abs: dataAbs, url: `/data/${LANG_DIR}/${name}` };
|
||||
found.push({ id: code, type: 'lang', layout: 'file', where, supported: true, manifest, files, strings: countStrings(r.json) });
|
||||
}
|
||||
|
||||
// folder packs: packs/<id>/pack.json
|
||||
for (const id of readDirSorted(packsDir)) {
|
||||
const dir = path.join(packsDir, id);
|
||||
let st;
|
||||
try { st = fs.statSync(dir); } catch { continue; }
|
||||
if (!st.isDirectory()) continue;
|
||||
const where = `packs/${id}/`;
|
||||
const man = path.join(dir, PACK_MANIFEST);
|
||||
if (!isFile(man)) { skipped.push({ where, problems: [`no ${PACK_MANIFEST}`] }); continue; }
|
||||
const r = readJson(man);
|
||||
if (r.error) { skipped.push({ where, problems: [`${PACK_MANIFEST}: ${r.error}`] }); continue; }
|
||||
const { manifest, problems, warnings: w } = normalizeManifest(r.json, { id, folder: true, app });
|
||||
warn(where, w);
|
||||
if (!manifest) { skipped.push({ where, problems }); continue; }
|
||||
const kind = PACK_TYPES[manifest.type];
|
||||
if (kind.status !== 'supported') {
|
||||
skipped.push({ where, problems: [`type "${manifest.type}" is planned (${kind.summary}), not loaded by this version`] });
|
||||
continue;
|
||||
}
|
||||
/** @type {Record<string, PackFile>} */
|
||||
const files = {};
|
||||
const missing = [];
|
||||
for (const [role, rel] of Object.entries(manifest.files)) {
|
||||
const abs = path.join(dir, ...rel.split('/'));
|
||||
if (!isFile(abs)) missing.push(`files.${role}: ${rel} is missing`);
|
||||
else if (!inside(dir, abs)) missing.push(`files.${role}: ${rel} leads out of the pack folder`);
|
||||
else files[role] = { rel, abs, url: fileUrl(id, rel) };
|
||||
}
|
||||
if (missing.length) { skipped.push({ where, problems: missing }); continue; }
|
||||
/** @type {Pack} */
|
||||
const pack = { id, type: manifest.type, layout: 'folder', where, supported: true, manifest, files };
|
||||
if (manifest.type === 'lang') {
|
||||
const ui = readJson(files.ui.abs);
|
||||
if (ui.error || !ui.json || typeof ui.json !== 'object' || Array.isArray(ui.json)) {
|
||||
skipped.push({ where, problems: [`files.ui: ${ui.error || 'not a JSON object of msgid → translation'}`] });
|
||||
continue;
|
||||
}
|
||||
pack.strings = countStrings(ui.json);
|
||||
}
|
||||
found.push(pack);
|
||||
}
|
||||
|
||||
// one pack per id (any case: one folder on Windows / macOS) and per language; the language folders' files first
|
||||
const packs = [];
|
||||
const ids = new Map();
|
||||
const langs = new Map();
|
||||
for (const p of found) {
|
||||
const idKey = p.id.toLowerCase();
|
||||
if (ids.has(idKey)) { skipped.push({ where: p.where, problems: [`the id "${p.id}" is taken by ${ids.get(idKey)}`] }); continue; }
|
||||
if (p.type === 'lang' && langs.has(p.manifest.lang)) {
|
||||
skipped.push({ where: p.where, problems: [`the language ${p.manifest.lang} is already provided by ${langs.get(p.manifest.lang)}`] });
|
||||
continue;
|
||||
}
|
||||
ids.set(idKey, p.where);
|
||||
if (p.type === 'lang') langs.set(p.manifest.lang, p.where);
|
||||
packs.push(p);
|
||||
}
|
||||
return { packs, skipped, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* The index body of scanned packs (shared/packs.js buildPackIndex).
|
||||
* @param {Pack[]} packs
|
||||
* @param {{ app?: string }} [opts]
|
||||
*/
|
||||
export function packIndexOf(packs, { app = APP_VERSION } = {}) {
|
||||
const entries = packs.filter((p) => p.supported).map((p) => packIndexEntry(p.manifest, {
|
||||
files: Object.fromEntries(Object.entries(p.files).map(([role, f]) => [role, f.url])),
|
||||
strings: p.strings,
|
||||
}));
|
||||
return buildPackIndex(entries, { app });
|
||||
}
|
||||
|
||||
/** What changes when a pack is added, removed or edited: names, sizes and times of the files the scan reads. */
|
||||
function signature({ publicDir, dataDir, packsDir }) {
|
||||
const parts = [];
|
||||
const add = (dir, depth, tag, namesOnly = false) => {
|
||||
for (const n of readDirSorted(dir)) {
|
||||
const abs = path.join(dir, n);
|
||||
let st;
|
||||
try { st = fs.statSync(abs); } catch { continue; }
|
||||
if (st.isDirectory()) {
|
||||
if (depth > 0) { parts.push(`${tag}${n}/`); add(abs, depth - 1, `${tag}${n}/`); }
|
||||
continue;
|
||||
}
|
||||
parts.push(namesOnly ? `${tag}${n}` : `${tag}${n}:${st.size}:${Math.floor(st.mtimeMs)}`);
|
||||
}
|
||||
};
|
||||
add(path.join(publicDir, LANG_DIR), 0, 'u:');
|
||||
add(path.join(dataDir, LANG_DIR), 0, 'd:', true); // a game-text file only counts by its presence
|
||||
add(packsDir, 3, 'p:');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry: the scan, re-done when the folders change (see the header).
|
||||
* @param {{ publicDir: string, dataDir: string, packsDir: string }} dirs
|
||||
* @param {{ log?: { info: Function, warn: Function }, app?: string, recheckMs?: number }} [opts]
|
||||
* recheckMs: how long a scan stays trusted before the folders are looked at again (default 1000; 0 = every call)
|
||||
*/
|
||||
export function createPackRegistry(dirs, { log = { info: noop, warn: noop }, app = APP_VERSION, recheckMs = 1000 } = {}) {
|
||||
let sig = null;
|
||||
let checkedAt = -Infinity;
|
||||
/** @type {ReturnType<typeof scanPacks> & { index: ReturnType<typeof packIndexOf>, byId: Map<string, Pack> }} */
|
||||
let state = null;
|
||||
const told = new Set();
|
||||
const tell = (fn, line) => { if (!told.has(line)) { told.add(line); fn(line); } };
|
||||
|
||||
function refresh(force = false) {
|
||||
const now = Date.now();
|
||||
if (state && !force && now - checkedAt < recheckMs) return state;
|
||||
checkedAt = now;
|
||||
const s = signature(dirs);
|
||||
if (state && s === sig) return state;
|
||||
const first = !state;
|
||||
sig = s;
|
||||
const scan = scanPacks(dirs, { app });
|
||||
state = { ...scan, index: packIndexOf(scan.packs, { app }), byId: new Map(scan.packs.map((p) => [p.id, p])) };
|
||||
const line = `[packs] ${scan.packs.length ? scan.packs.map((p) => `${p.type} ${p.type === 'lang' ? p.manifest.lang : p.id} (${p.manifest.name})`).join(', ') : 'none'}`;
|
||||
if (first || scan.packs.length) tell((m) => log.info(m), line);
|
||||
for (const k of scan.skipped) tell((m) => log.warn(m), `[packs] ${k.where} skipped: ${k.problems.join('; ')}`);
|
||||
for (const w of scan.warnings) tell((m) => log.warn(m), `[packs] ${w.where}: ${w.warning}`);
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
/** Re-read the folders now if they changed (force: even within recheckMs). */
|
||||
refresh: (force = false) => { refresh(force); },
|
||||
/** The loaded packs, optionally of one type. @param {string} [type] @returns {Pack[]} */
|
||||
list: (type) => refresh().packs.filter((p) => !type || p.type === type),
|
||||
/** The index body (GET /packs/index.json). */
|
||||
index: () => refresh().index,
|
||||
/** Packs that were not loaded and why, and the warnings of the loaded ones. */
|
||||
report: () => { const s = refresh(); return { skipped: s.skipped, warnings: s.warnings }; },
|
||||
/**
|
||||
* The file behind /packs/<id>/<rel>, or null: only a file a supported folder pack names.
|
||||
* @param {string} id
|
||||
* @param {string} rel
|
||||
*/
|
||||
servable(id, rel) {
|
||||
const p = refresh().byId.get(id);
|
||||
if (!p || !p.supported || p.layout !== 'folder') return null;
|
||||
for (const f of Object.values(p.files)) if (f.rel === rel) return f.abs;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user