diff --git a/public/configuration/asset-loader.js b/public/configuration/asset-loader.js index c1cfde3..19e2307 100644 --- a/public/configuration/asset-loader.js +++ b/public/configuration/asset-loader.js @@ -1,9 +1,9 @@ (() => { const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026"); const MODE_DEFAULTS = { - distObfuscationEnabled: true, - secureAssetsEnabled: true, - secureApiEnabled: true + distObfuscationEnabled: false, + secureAssetsEnabled: false, + secureApiEnabled: false }; const isDebug = () => { @@ -37,6 +37,9 @@ }; 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); }; @@ -81,10 +84,17 @@ 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 resolveAssetCandidates(path)) { + for(const candidate of expandAssetCandidates(path)) { try { debug("loader: try " + candidate.href); const response = await fetch(withCacheBust(candidate), { cache: "no-store" }); @@ -110,9 +120,39 @@ 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 = resolveAssetCandidates(path)[0]; - href.searchParams.set("v", Date.now().toString(36)); + const href = await probePlainAsset(path, ["text/css"]); await new Promise((resolve, reject) => { const link = document.createElement("link"); link.rel = "stylesheet"; @@ -136,9 +176,8 @@ }; const importPlainJs = async (path) => { - const href = resolveAssetCandidates(path)[0]; - href.searchParams.set("v", Date.now().toString(36)); - debug("loader: importing plain js"); + const href = await probePlainAsset(path, ["javascript", "ecmascript"]); + debug("loader: importing plain js " + href.href); await import(href.href); debug("loader: plain js imported"); }; @@ -164,24 +203,135 @@ } }; + const fetchManifest = async () => { + const base = getBase(); + const candidates = [ + 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); + return { manifest: json, base: new URL(".", candidate.href) }; + } + } 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.origin + "/").pathname; + }; + + const isLoaderUrl = (href) => /(?:^|\/)bootstrap\.js(?:$|\?|#)/i.test(href) || /(?:^|\/)asset-loader\.js(?:$|\?|#)/i.test(href); + + const fetchEntryFromIndexHtml = async () => { + const base = getBase(); + const candidates = [ + new URL("/index.html", base.origin + "/"), + new URL("/", base.origin + "/") + ]; + for(const candidate of candidates) { + 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 [cssBytes, jsBytes] = await Promise.all([ - loadDatAsset("./assets/app.css.dat"), - loadDatAsset("./assets/app.js.dat") + const [cssBytesList, jsBytes] = await Promise.all([ + Promise.all(cssPaths.map(path => loadDatAsset(path + ".dat"))), + loadDatAsset(jsPath + ".dat") ]); - injectCssText(cssBytes); + cssBytesList.forEach(bytes => injectCssText(bytes)); await importBytes(jsBytes); return; } - await loadPlainCss("./assets/app.css"); - await importPlainJs("./assets/app.js"); + 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."; }); -})(); \ No newline at end of file +})(); diff --git a/public/configuration/bootstrap.js b/public/configuration/bootstrap.js index f2a9d7e..13c1d8f 100644 --- a/public/configuration/bootstrap.js +++ b/public/configuration/bootstrap.js @@ -1,11 +1,14 @@ (() => { - const API_BASE = "https://nitro.slogga.it:2096"; + 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; @@ -69,18 +72,34 @@ } }; + 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", getBase())); + const url = withCacheBust(new URL("./asset-loader.js", LOADER_BASE)); await import(url.href); }; - const loadSecureBootstrap = async () => { - if(!API_BASE) throw new Error("Missing apiBaseUrl for secure bootstrap."); + 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 = API_BASE.replace(/\/$/, ""); + const base = apiBase.replace(/\/$/, ""); const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -120,14 +139,22 @@ }; (async () => { - try { - await loadSecureBootstrap(); - } catch(error) { - console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error); - await loadPlainBootstrap(); + 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."; }); -})(); \ No newline at end of file +})(); diff --git a/vite.config.mjs b/vite.config.mjs index 7bf9554..a73910a 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -16,13 +16,17 @@ export default defineConfig({ rendererRoot, ] }, - + proxy: { + '/api': { + target: process.env.AUTH_PROXY_TARGET || 'http://192.168.0.181:2096', + changeOrigin: true, + } + } }, resolve: { tsconfigPaths: true, alias: { '@': resolve(__dirname, 'src'), - '@layout': resolve(__dirname, 'src/layout'), '~': resolve(__dirname, 'node_modules'), '@nitrots/api': resolve(rendererRoot, 'packages/api/src/index.ts'), '@nitrots/assets': resolve(rendererRoot, 'packages/assets/src/index.ts'), @@ -43,17 +47,21 @@ export default defineConfig({ } }, build: { - assetsInlineLimit: 4096, + assetsInlineLimit: 102400, chunkSizeWarningLimit: 200000, + manifest: true, rollupOptions: { - input: resolve(__dirname, 'index.html'), output: { - inlineDynamicImports: true, - entryFileNames: 'assets/app.js', - chunkFileNames: 'assets/app.js', - assetFileNames: assetInfo => assetInfo.name && assetInfo.name.endsWith('.css') - ? 'assets/app.css' - : 'src/assets/[name]-[hash].[ext]' + assetFileNames: 'src/assets/[name]-[hash].[ext]', + manualChunks: id => + { + if(id.includes('node_modules')) + { + if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3') || id.includes('Nitro_Render_V3')) return 'nitro-renderer'; + + return 'vendor'; + } + } } } }