246 lines
12 KiB
JavaScript
246 lines
12 KiB
JavaScript
// 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;
|
|
},
|
|
};
|
|
}
|