Files
Nitro-V3/scripts/write-asset-loader.mjs
T
medievalshell c685c997a3 feat(loading): redesigned loader with progress bar, task labels, configurable assets
Loading screen overhaul:
- LoadingView: Nitro V3 logo flush top-left, loading.gif at viewport
  centre, large progress bar (max 900px / 90vw, h-8, gradient + glow)
  anchored bottom-centre with the percentage rendered inside the bar in
  Poppins, plus a friendly stage label underneath. Logo + background +
  progress bar colour overridable via renderer-config keys
  (loading.logo.url, loading.background, loading.progress.color).
- App.tsx: wired a real loadingProgress (0->100) + loadingTask driven by
  the boot pipeline: config init (10), renderer (20), per-warmup-task
  bumps for AssetManager/Localization/AvatarRender/SoundManager (25->70),
  session managers (78/85/92), Communication (98), ready (100). Each bump
  carries a task label looked up via a new taskLabel(key, fallback)
  helper so the Italian baseline ("Sto caricando il guardaroba",
  "Connessione al server", ...) can be translated by editing
  renderer-config; fallback keeps current strings if the key is missing.
- AvatarEffectsView: replace raw fetch(url).json() with
  loadGamedata(url) so the effectmap root manifest (JSON5 with
  // comments) parses correctly and supports the core/custom/seasonal
  tier merge.
- fallbackToLogin: respect login.screen.enabled=false. When login is
  disabled (SSO-only deployments), init failures now route to
  showSessionExpired() (home + diagnostic) instead of rendering an empty
  LoginView placeholder.
- scripts/write-asset-loader.mjs: the pre-React shell rendered into
  #root before the JS bundle takes over was a light-blue login skeleton
  (linear gradient + two grey rectangles) producing a visible flash
  before the real loader appeared. Replaced with the same
  radial-gradient the LoadingView paints — the handoff is now invisible.
- renderer-config.example: document the 13 loader keys so operators can
  copy & translate.
2026-05-21 00:22:17 +02:00

545 lines
20 KiB
JavaScript

import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = resolve(__dirname, '..');
const OUTPUT_DIR = resolve(ROOT, 'public', 'configuration');
const BOOTSTRAP_JS = `(() => {
const FALLBACK_API_BASE = "";
const getBase = () => {
const source = document.currentScript?.src || location.href;
return new URL(".", source);
};
const LOADER_BASE = getBase();
window.__nitroLoaderBase = LOADER_BASE.href;
const withCacheBust = (url) => {
url.searchParams.set("v", Date.now().toString(36));
return url;
};
const bytesToBase64 = (buffer) => {
let binary = "";
const bytes = new Uint8Array(buffer);
for(let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
};
const hexValue = (code) => {
if(code >= 48 && code <= 57) return code - 48;
if(code >= 65 && code <= 70) return code - 55;
if(code >= 97 && code <= 102) return code - 87;
return -1;
};
const hexToBytes = (hex) => {
const normalized = hex.trim();
if((normalized.length % 2) !== 0) throw new Error("Invalid encrypted hex payload.");
const bytes = new Uint8Array(normalized.length / 2);
for(let i = 0; i < bytes.length; i++) {
const high = hexValue(normalized.charCodeAt(i * 2));
const low = hexValue(normalized.charCodeAt((i * 2) + 1));
if(high < 0 || low < 0) throw new Error("Invalid encrypted hex payload.");
bytes[i] = (high << 4) | low;
}
return bytes;
};
const deriveAesKey = async (privateKey, serverKeyBase64) => {
const serverBytes = Uint8Array.from(atob(serverKeyBase64), char => char.charCodeAt(0));
const serverKey = await crypto.subtle.importKey("spki", serverBytes, { name: "ECDH", namedCurve: "P-256" }, false, []);
const secret = await crypto.subtle.deriveBits({ name: "ECDH", public: serverKey }, privateKey, 256);
const salt = new TextEncoder().encode("nitro-secure-assets-v1");
const material = new Uint8Array(secret.byteLength + salt.length);
material.set(new Uint8Array(secret), 0);
material.set(salt, secret.byteLength);
const hash = await crypto.subtle.digest("SHA-256", material);
return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["decrypt"]);
};
const decryptPayload = async (key, response) => {
if(response.headers.get("X-Nitro-Sec") !== "1") return response.text();
const bytes = hexToBytes(await response.text());
if(bytes.length < 13) throw new Error("Encrypted response is too short.");
const iv = bytes.slice(0, 12);
const payload = bytes.slice(12);
const clear = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, payload);
return new TextDecoder().decode(clear);
};
const importTextModule = async (sourceText) => {
const blobUrl = URL.createObjectURL(new Blob([sourceText], { type: "text/javascript" }));
try {
await import(blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
};
const fetchPlainClientMode = async () => {
try {
const url = withCacheBust(new URL("./client-mode.json", LOADER_BASE));
const response = await fetch(url, { cache: "no-store" });
if(!response.ok) throw new Error("HTTP " + response.status);
const payload = await response.json();
if(payload && typeof payload === "object") {
window.__nitroClientMode = payload;
return payload;
}
} catch(error) {
console.warn("[Nitro] client-mode fetch failed:", error?.message || error);
}
return null;
};
const loadPlainBootstrap = async () => {
const url = withCacheBust(new URL("./asset-loader.js", LOADER_BASE));
await import(url.href);
};
const loadSecureBootstrap = async (apiBase) => {
if(!apiBase) throw new Error("Missing apiBaseUrl for secure bootstrap.");
const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey);
const publicKey = bytesToBase64(publicKeyBuffer);
const base = apiBase.replace(/\\/$/, "");
const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: publicKey })
});
if(!bootstrapResponse.ok) throw new Error("Secure bootstrap failed: HTTP " + bootstrapResponse.status);
const bootstrapPayload = await bootstrapResponse.json();
if(!bootstrapPayload || typeof bootstrapPayload.key !== "string" || !bootstrapPayload.key.length) {
throw new Error("Secure bootstrap returned an invalid server key.");
}
const sessionKey = await deriveAesKey(pair.privateKey, bootstrapPayload.key);
const fetchSecureConfig = async (file) => {
const url = new URL(base + "/nitro-sec/file");
url.searchParams.set("kind", "config");
url.searchParams.set("file", file);
url.searchParams.set("v", Date.now().toString(36));
const response = await fetch(url.toString(), {
headers: { "X-Nitro-Key": publicKey },
cache: "no-store"
});
if(!response.ok) throw new Error("Failed to load secure config " + file + ": HTTP " + response.status);
return decryptPayload(sessionKey, response);
};
const modeText = await fetchSecureConfig("client-mode.json");
window.__nitroClientMode = JSON.parse(modeText);
const loaderText = await fetchSecureConfig("asset-loader.js");
await importTextModule(loaderText);
};
(async () => {
const mode = await fetchPlainClientMode();
const wantsSecure = !!(mode && mode.secureAssetsEnabled);
const apiBase = (mode && typeof mode.apiBaseUrl === "string" && mode.apiBaseUrl) || FALLBACK_API_BASE;
if(wantsSecure) {
try {
await loadSecureBootstrap(apiBase);
return;
} catch(error) {
console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error);
}
}
await loadPlainBootstrap();
})().catch(error => {
console.error(error);
document.body.textContent = "Unable to load client.";
});
})();
`;
const ASSET_LOADER_JS = `(() => {
const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026");
const MODE_DEFAULTS = {
distObfuscationEnabled: false,
secureAssetsEnabled: false,
secureApiEnabled: false
};
const isDebug = () => {
try {
const search = new URLSearchParams(location.search);
return search.get("loaderDebug") === "1" || localStorage.getItem("nitro.loader.debug") === "1";
} catch {
return false;
}
};
const debug = (message) => {
try {
window.__nitroLoaderDebug = message;
const log = Array.isArray(window.__nitroLoaderDebugLog) ? window.__nitroLoaderDebugLog : [];
log.push(message);
window.__nitroLoaderDebugLog = log.slice(-30);
if(!isDebug()) {
document.getElementById("nitro-loader-debug")?.remove();
return;
}
let node = document.getElementById("nitro-loader-debug");
if(!node) {
node = document.createElement("div");
node.id = "nitro-loader-debug";
node.style.cssText = "position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";
document.body.appendChild(node);
}
node.textContent = window.__nitroLoaderDebugLog.slice(-10).join("\\n");
} catch {}
};
const getBase = () => {
if(typeof window.__nitroLoaderBase === "string" && window.__nitroLoaderBase) {
try { return new URL(window.__nitroLoaderBase); } catch {}
}
const source = document.currentScript?.src || location.href;
return new URL(".", source);
};
const getDeployBase = () => {
try { return new URL("..", getBase()); }
catch { return new URL("/", location.href); }
};
const withCacheBust = (url) => {
url.searchParams.set("v", Date.now().toString(36));
return url;
};
const renderShell = () => {
const root = document.getElementById("root");
if(!root || root.firstChild) return;
// Match the React LoadingView background so the pre-React shell paints
// the same gradient — no light-blue login-skeleton flash before the
// loader takes over.
root.innerHTML = '<div style="position:fixed;inset:0;background:radial-gradient(#1d1a24,#003a6b);overflow:hidden;z-index:1"></div>';
};
const decodeAsset = (bytes) => {
const output = new Uint8Array(bytes.length);
for(let index = 0; index < bytes.length; index++) {
output[index] = bytes[index] ^ ASSET_KEY[index % ASSET_KEY.length] ^ ((index * 31) & 255);
}
return output;
};
const gunzip = async (bytes) => {
if(!("DecompressionStream" in self)) throw new Error("gzip decompression unsupported");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
return new Uint8Array(await new Response(stream).arrayBuffer());
};
const resolveAssetCandidates = (path) => {
const base = getBase();
const deploy = getDeployBase();
const normalized = path.replace(/^\\.\\//, "");
const file = normalized.split("/").pop();
const relative = normalized.replace(/^\\//, "");
const urls = [
new URL("src/assets/" + file, deploy),
new URL("assets/" + file, deploy),
new URL(relative, deploy),
new URL("./src/assets/" + file, base),
new URL("./assets/" + file, base),
new URL("/src/assets/" + file, base.origin),
new URL("/assets/" + file, base.origin),
new URL("/client/src/assets/" + file, base.origin),
new URL("/client/assets/" + file, base.origin)
];
return [...new Map(urls.map(url => [url.href, url])).values()];
};
const expandAssetCandidates = (path) => {
const base = getBase();
if(/^https?:\\/\\//i.test(path)) return [new URL(path)];
if(path.startsWith("/")) return [new URL(path, base.origin + "/")];
return resolveAssetCandidates(path);
};
const fetchBytes = async (path) => {
let error = null;
debug("loader: fetching " + path);
for(const candidate of expandAssetCandidates(path)) {
try {
debug("loader: try " + candidate.href);
const response = await fetch(withCacheBust(candidate), { cache: "no-store" });
if(!response.ok) {
error = new Error("asset " + candidate.pathname + " " + response.status);
continue;
}
debug("loader: ok " + candidate.href);
return new Uint8Array(await response.arrayBuffer());
} catch(caught) {
error = caught;
}
}
throw error || new Error("asset " + path + " not found");
};
const loadDatAsset = async (path) => gunzip(decodeAsset(await fetchBytes(path)));
const injectCssText = (bytes) => {
const node = document.createElement("style");
node.textContent = new TextDecoder().decode(bytes);
document.head.appendChild(node);
debug("loader: css injected from dat");
};
const matchesContentType = (contentType, accepted) => {
if(!contentType) return true;
return accepted.some(token => contentType.indexOf(token) !== -1);
};
const probePlainAsset = async (path, accepted) => {
let lastError = null;
for(const candidate of expandAssetCandidates(path)) {
try {
debug("loader: probe " + candidate.href);
const response = await fetch(withCacheBust(candidate), { cache: "no-store" });
if(!response.ok) {
lastError = new Error("asset " + candidate.pathname + " " + response.status);
continue;
}
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if(!matchesContentType(contentType, accepted)) {
lastError = new Error("asset " + candidate.pathname + " wrong type " + contentType);
continue;
}
debug("loader: probe ok " + candidate.href);
const url = new URL(candidate.href);
url.searchParams.set("v", Date.now().toString(36));
return url;
} catch(caught) {
lastError = caught;
}
}
throw lastError || new Error("asset " + path + " not found");
};
const loadPlainCss = async (path) => {
const href = await probePlainAsset(path, ["text/css"]);
await new Promise((resolve, reject) => {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href.href;
link.onload = () => resolve();
link.onerror = () => reject(new Error("plain css failed"));
document.head.appendChild(link);
});
debug("loader: css linked");
};
const importBytes = async (bytes) => {
const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "text/javascript" }));
try {
debug("loader: importing app blob");
await import(blobUrl);
debug("loader: app blob imported");
} finally {
URL.revokeObjectURL(blobUrl);
}
};
const importPlainJs = async (path) => {
const href = await probePlainAsset(path, ["javascript", "ecmascript"]);
debug("loader: importing plain js " + href.href);
await import(href.href);
debug("loader: plain js imported");
};
const readClientMode = async () => {
try {
if(window.__nitroClientMode && typeof window.__nitroClientMode === "object") {
debug("loader: client-mode preset");
return window.__nitroClientMode;
}
const url = withCacheBust(new URL("./client-mode.json", getBase()));
const response = await fetch(url, { cache: "no-store" });
if(!response.ok) throw new Error("client-mode " + response.status);
const payload = await response.json();
const mode = { ...MODE_DEFAULTS, ...(payload && typeof payload === "object" ? payload : {}) };
window.__nitroClientMode = mode;
debug("loader: client-mode loaded");
return mode;
} catch(error) {
window.__nitroClientMode = { ...MODE_DEFAULTS };
debug("loader: client-mode fallback " + (error?.message || error));
return window.__nitroClientMode;
}
};
const fetchManifest = async () => {
const base = getBase();
const deploy = getDeployBase();
const candidates = [
new URL(".vite/manifest.json", deploy),
new URL("manifest.json", deploy),
new URL(".vite/manifest.json", base.origin + "/"),
new URL("manifest.json", base.origin + "/"),
new URL(".vite/manifest.json", base),
new URL("manifest.json", base)
];
const seen = new Set();
for(const candidate of candidates) {
if(seen.has(candidate.href)) continue;
seen.add(candidate.href);
try {
const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" });
if(!response.ok) continue;
const json = await response.json();
if(json && typeof json === "object") {
debug("loader: manifest from " + candidate.href);
let manifestBase = new URL(".", candidate.href);
if(/\\/\\.vite\\/manifest\\.json$/.test(candidate.pathname)) {
manifestBase = new URL("..", manifestBase);
}
return { manifest: json, base: manifestBase };
}
} catch {}
}
return null;
};
const findEntryFromManifest = (manifest) => {
let bootstrap = null;
for(const key of Object.keys(manifest)) {
const entry = manifest[key];
if(!entry || typeof entry !== "object" || !entry.isEntry) continue;
if(/bootstrap\\./.test(key) || /bootstrap\\./.test(entry.file || "")) {
bootstrap = entry;
break;
}
if(!bootstrap) bootstrap = entry;
}
if(!bootstrap) return null;
const css = Array.isArray(bootstrap.css) ? bootstrap.css.slice() : [];
return { js: bootstrap.file, css };
};
const resolveManifestPath = (manifestBase, file) => {
if(/^https?:\\/\\//i.test(file)) return file;
if(file.startsWith("/")) return file;
return new URL(file, manifestBase).pathname;
};
const isLoaderUrl = (href) => /(?:^|\\/)bootstrap\\.js(?:$|\\?|#)/i.test(href) || /(?:^|\\/)asset-loader\\.js(?:$|\\?|#)/i.test(href);
const fetchEntryFromIndexHtml = async () => {
const base = getBase();
const deploy = getDeployBase();
const candidates = [
new URL("index.html", deploy),
new URL("./", deploy),
new URL("/index.html", base.origin + "/"),
new URL("/", base.origin + "/")
];
const seen = new Set();
for(const candidate of candidates) {
if(seen.has(candidate.href)) continue;
seen.add(candidate.href);
try {
const response = await fetch(withCacheBust(new URL(candidate.href)), { cache: "no-store" });
if(!response.ok) continue;
const contentType = (response.headers.get("content-type") || "").toLowerCase();
if(contentType && contentType.indexOf("html") === -1) continue;
const html = await response.text();
const doc = new DOMParser().parseFromString(html, "text/html");
if(!doc) continue;
const resolveAttr = (raw) => {
if(!raw) return "";
if(/^https?:\\/\\//i.test(raw)) return raw;
try { return new URL(raw, candidate.href).pathname; }
catch { return raw; }
};
const scriptNode = Array.from(doc.querySelectorAll('script[type="module"][src]'))
.map(node => node.getAttribute("src") || "")
.find(src => src && !isLoaderUrl(src));
if(!scriptNode) continue;
const cssNodes = Array.from(doc.querySelectorAll('link[rel="stylesheet"][href]'))
.map(node => node.getAttribute("href") || "")
.filter(href => href && !isLoaderUrl(href));
const jsAbs = resolveAttr(scriptNode);
const cssAbs = cssNodes.map(resolveAttr);
debug("loader: entry from index.html " + jsAbs);
return { js: jsAbs, css: cssAbs };
} catch {}
}
return null;
};
(async () => {
debug("loader: start");
renderShell();
const mode = await readClientMode();
let jsPath = null;
let cssPaths = [];
const manifestResult = await fetchManifest();
if(manifestResult) {
const entry = findEntryFromManifest(manifestResult.manifest);
if(entry) {
jsPath = resolveManifestPath(manifestResult.base, entry.js);
if(entry.css.length) cssPaths = entry.css.map(file => resolveManifestPath(manifestResult.base, file));
debug("loader: entry from manifest " + jsPath);
}
}
if(!jsPath) {
const indexEntry = await fetchEntryFromIndexHtml();
if(indexEntry) {
jsPath = indexEntry.js;
if(indexEntry.css.length) cssPaths = indexEntry.css;
}
}
if(!jsPath) {
jsPath = "./assets/app.js";
cssPaths = ["./assets/app.css"];
debug("loader: entry fallback to app.js/app.css");
}
if(mode.distObfuscationEnabled) {
const [cssBytesList, jsBytes] = await Promise.all([
Promise.all(cssPaths.map(path => loadDatAsset(path + ".dat"))),
loadDatAsset(jsPath + ".dat")
]);
cssBytesList.forEach(bytes => injectCssText(bytes));
await importBytes(jsBytes);
return;
}
for(const css of cssPaths) await loadPlainCss(css);
await importPlainJs(jsPath);
})().catch(error => {
console.error(error);
debug("loader: failed " + (error?.message || error));
document.body.textContent = "Unable to load client.";
});
})();
`;
const writeFileEnsuring = async (path, contents) => {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, contents, 'utf8');
console.log(`[write-asset-loader] wrote ${path}`);
};
await writeFileEnsuring(resolve(OUTPUT_DIR, 'bootstrap.js'), BOOTSTRAP_JS);
await writeFileEnsuring(resolve(OUTPUT_DIR, 'asset-loader.js'), ASSET_LOADER_JS);