From c685c997a329f11fc85df09309f3e1f85b350d2c Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 21 May 2026 00:22:17 +0200 Subject: [PATCH 1/7] feat(loading): redesigned loader with progress bar, task labels, configurable assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- public/configuration/asset-loader.js | 5 +- public/configuration/renderer-config.example | 20 +++ scripts/write-asset-loader.mjs | 5 +- src/App.tsx | 113 ++++++++++++---- .../avatar-effects/AvatarEffectsView.tsx | 9 +- src/components/loading/LoadingView.tsx | 123 ++++++++++++++++-- 6 files changed, 233 insertions(+), 42 deletions(-) diff --git a/public/configuration/asset-loader.js b/public/configuration/asset-loader.js index 7483733..8a4a916 100644 --- a/public/configuration/asset-loader.js +++ b/public/configuration/asset-loader.js @@ -57,7 +57,10 @@ const renderShell = () => { const root = document.getElementById("root"); if(!root || root.firstChild) return; - root.innerHTML = '
'; + // 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 = '
'; }; const decodeAsset = (bytes) => { diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index cff14e5..4d8fd1f 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -48,6 +48,26 @@ "timezone.settings": "Europe/Amsterdam", "youtube.publish.disabled": false, "user.badges.group.slot.enabled": true, + + "_comment_loading_screen": "Schermata di caricamento — sostituibili per traduzione/branding. Logo, sfondo e colore della barra usano lo standard CSS (URL, gradient, colore esadecimale). Le label compaiono sotto la barra di progresso man mano che ogni fase del boot completa.", + "loading.logo.url": "", + "loading.background": "", + "loading.progress.color": "linear-gradient(90deg,#4f8cff,#2563eb)", + "loading.task.boot": "Avvio in corso...", + "loading.task.session": "Verifica sessione", + "loading.task.renderer": "Inizializzazione renderer", + "loading.task.warmup": "Caricamento contenuti...", + "loading.task.assets": "Sto caricando gli asset di gioco", + "loading.task.localization": "Sto caricando le traduzioni", + "loading.task.avatar": "Sto caricando il guardaroba", + "loading.task.sounds": "Sto caricando i suoni", + "loading.task.startsession": "Avvio sessione", + "loading.task.userdata": "Caricamento dati utente", + "loading.task.rooms": "Caricamento stanze", + "loading.task.engine": "Caricamento engine grafico", + "loading.task.connect": "Connessione al server", + "loading.task.ready": "Pronto!", + "login.screen.enabled": true, "login.endpoint": "${api.url}/api/auth/login", "login.register.endpoint": "${api.url}/api/auth/register", diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs index 4b082fc..44976c6 100644 --- a/scripts/write-asset-loader.mjs +++ b/scripts/write-asset-loader.mjs @@ -228,7 +228,10 @@ const ASSET_LOADER_JS = `(() => { const renderShell = () => { const root = document.getElementById("root"); if(!root || root.firstChild) return; - root.innerHTML = '
'; + // 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 = '
'; }; const decodeAsset = (bytes) => { diff --git a/src/App.tsx b/src/App.tsx index 6c3f00f..6bf4d4b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -72,6 +72,29 @@ export const App: FC<{}> = props => const [ showLogin, setShowLogin ] = useState(false); const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin()); const [ prepareTrigger, setPrepareTrigger ] = useState(0); + const [ loadingProgress, setLoadingProgress ] = useState(0); + const [ loadingTask, setLoadingTask ] = useState(''); + // Look up a loader-stage label from renderer-config so the strings the user + // sees during the boot ("Sto caricando il guardaroba", "Connessione…") can + // be translated by editing the JSON/JSON5 config — fallback keeps the + // Italian baseline shipped with the client. + const taskLabel = useCallback((key: string, fallback: string): string => + { + try + { + const raw = GetConfiguration().getValue(key, ''); + return (typeof raw === 'string' && raw.length) ? raw : fallback; + } + catch + { + return fallback; + } + }, []); + const bumpProgress = useCallback((value: number, task?: string) => + { + setLoadingProgress(prev => (value > prev ? value : prev)); + if(task !== undefined) setLoadingTask(task); + }, []); const warmupPromiseRef = useRef>(null); const rendererPromiseRef = useRef>(null); const gameInitPromiseRef = useRef | null>(null); @@ -104,8 +127,36 @@ export const App: FC<{}> = props => catch {} }, []); + const showSessionExpired = useCallback(() => + { + console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)'); + clearStoredCredentials(); + + const baseUrl = window.location.origin + '/'; + setHomeUrl(baseUrl); + setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.'); + setIsReady(false); + setShowLogin(false); + setIsEnteringHotel(false); + }, [ clearStoredCredentials ]); + const fallbackToLogin = useCallback(() => { + // When login.screen.enabled is false this hotel uses SSO-only auth + // (CMS issues the ticket and redirects here). Surfacing a login form + // on init failure would just dump an empty/broken placeholder, since + // the form's backgrounds and Turnstile aren't even configured. Send + // the user back to the hotel home page instead. + const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); + const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; + + if(!loginScreenEnabled) + { + console.warn('[App] fallbackToLogin — login.screen.enabled=false, redirecting to home instead'); + showSessionExpired(); + return; + } + // Using console.warn (not NitroLogger.log) on purpose: NitroLogger // is gated on LOG_DEBUG, which only flips to true once startWarmup's // GetConfiguration().init() completes. Auth-failure paths fire before @@ -119,20 +170,7 @@ export const App: FC<{}> = props => setIsReady(false); setShowLogin(true); setIsEnteringHotel(false); - }, [ clearStoredCredentials ]); - - const showSessionExpired = useCallback(() => - { - console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)'); - clearStoredCredentials(); - - const baseUrl = window.location.origin + '/'; - setHomeUrl(baseUrl); - setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.'); - setIsReady(false); - setShowLogin(false); - setIsEnteringHotel(false); - }, [ clearStoredCredentials ]); + }, [ clearStoredCredentials, showSessionExpired ]); const applySsoTicket = useCallback((ssoTicket: string) => { @@ -352,6 +390,7 @@ export const App: FC<{}> = props => warmupPromiseRef.current = (async () => { await GetConfiguration().init(); + bumpProgress(25, taskLabel('loading.task.warmup', 'Caricamento contenuti...')); GetTicker().maxFPS = GetConfiguration().getValue('system.fps.max', 24); NitroLogger.LOG_DEBUG = GetConfiguration().getValue('system.log.debug', true); @@ -388,18 +427,29 @@ export const App: FC<{}> = props => loginImageUrls.forEach(preloadImage); gamedataUrls.forEach(url => preloadUrl(url)); - await Promise.all( - [ - GetAssetManager().downloadAssets(assetUrls), - GetLocalizationManager().init(), - GetAvatarRenderManager().init(), - GetSoundManager().init() - ] - ); + // Wire each warmup task to a progress bump so the bar reflects + // real subsystem-init completion, not a fake timer. Range 25→70. + // Each task carries a friendly label so the user sees what is + // currently being prepared instead of raw file names. + const warmupTasks: { promise: Promise; label: string }[] = [ + { promise: GetAssetManager().downloadAssets(assetUrls), label: taskLabel('loading.task.assets', 'Sto caricando gli asset di gioco') }, + { promise: GetLocalizationManager().init(), label: taskLabel('loading.task.localization', 'Sto caricando le traduzioni') }, + { promise: GetAvatarRenderManager().init(), label: taskLabel('loading.task.avatar', 'Sto caricando il guardaroba') }, + { promise: GetSoundManager().init(), label: taskLabel('loading.task.sounds', 'Sto caricando i suoni') } + ]; + let warmupDone = 0; + const warmupStart = 25; + const warmupSpan = 45; + await Promise.all(warmupTasks.map(t => t.promise.then(value => + { + warmupDone++; + bumpProgress(warmupStart + Math.round((warmupSpan * warmupDone) / warmupTasks.length), t.label); + return value; + }))); })(); return warmupPromiseRef.current; - }, [ startRenderer ]); + }, [ startRenderer, bumpProgress, taskLabel ]); useEffect(() => { @@ -434,12 +484,18 @@ export const App: FC<{}> = props => urlSso: new URLSearchParams(window.location.search).get('sso') }); + const bootLabel = taskLabel('loading.task.boot', 'Avvio in corso...'); + setLoadingProgress(0); + setLoadingTask(bootLabel); + bumpProgress(5, bootLabel); + try { if(!window.NitroConfig) throw new Error('NitroConfig is not defined!'); let ssoTicket = window.NitroConfig['sso.ticket']; if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket); + bumpProgress(10, taskLabel('loading.task.session', 'Verifica sessione')); if(!ssoTicket || ssoTicket === '') { @@ -506,17 +562,23 @@ export const App: FC<{}> = props => } const renderer = await startRenderer(width, height); + bumpProgress(20, taskLabel('loading.task.renderer', 'Inizializzazione renderer')); await startWarmup(width, height); + bumpProgress(70, taskLabel('loading.task.startsession', 'Avvio sessione')); if(!gameInitPromiseRef.current) { gameInitPromiseRef.current = (async () => { await GetSessionDataManager().init(); + bumpProgress(78, taskLabel('loading.task.userdata', 'Caricamento dati utente')); await GetRoomSessionManager().init(); + bumpProgress(85, taskLabel('loading.task.rooms', 'Caricamento stanze')); await GetRoomEngine().init(); + bumpProgress(92, taskLabel('loading.task.engine', 'Caricamento engine grafico')); await GetCommunication().init(); + bumpProgress(98, taskLabel('loading.task.connect', 'Connessione al server')); })(); } @@ -545,6 +607,7 @@ export const App: FC<{}> = props => GetTicker().add(ticker => GetTexturePool().run()); } + bumpProgress(100, taskLabel('loading.task.ready', 'Pronto!')); setIsReady(true); setShowLogin(false); setIsEnteringHotel(false); @@ -581,12 +644,12 @@ export const App: FC<{}> = props => if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current); if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current); }; - }, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin ]); + }, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin, bumpProgress, taskLabel ]); return ( { !isReady && !showLogin && - 0 } message={ errorMessage } homeUrl={ homeUrl } /> } + 0 } message={ errorMessage } homeUrl={ homeUrl } progress={ loadingProgress } currentTask={ loadingTask } /> } { !isReady && showLogin && } { isReady && } { /* Reconnect overlay must NOT render before we've actually entered diff --git a/src/components/avatar-effects/AvatarEffectsView.tsx b/src/components/avatar-effects/AvatarEffectsView.tsx index 887b9fb..eed77d8 100644 --- a/src/components/avatar-effects/AvatarEffectsView.tsx +++ b/src/components/avatar-effects/AvatarEffectsView.tsx @@ -1,4 +1,5 @@ import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { loadGamedata } from '@nitrots/utils'; import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa'; import { LocalizeText, SendMessageComposer } from '../../api'; @@ -65,9 +66,11 @@ export const AvatarEffectsView: FC<{}> = () => { try { - const response = await fetch(url); - if(!response.ok) throw new Error(`HTTP ${ response.status }`); - const json = await response.json(); + // The effectmap is served either as a single JSON file or as a + // tiered directory with core/custom/seasonal manifests using + // JSON5 syntax (// comments allowed). loadGamedata picks the + // right mode for us and merges tiers. + const json = await loadGamedata<{ effects?: EffectMapEntry[] }>(url); if(cancelled) return; const list: EffectMapEntry[] = Array.isArray(json?.effects) diff --git a/src/components/loading/LoadingView.tsx b/src/components/loading/LoadingView.tsx index 30e3c0c..f0542dd 100644 --- a/src/components/loading/LoadingView.tsx +++ b/src/components/loading/LoadingView.tsx @@ -1,22 +1,77 @@ -import { FC } from 'react'; +import { GetConfiguration } from '@nitrots/nitro-renderer'; +import { FC, useMemo } from 'react'; import loadingGif from '@/assets/images/loading/loading.gif'; +import nitroV3Logo from '@/assets/images/notifications/nitro_v3.png'; import { Base, Column, Text } from '../../common'; interface LoadingViewProps { isError?: boolean; message?: string; homeUrl?: string; + progress?: number; + currentTask?: string; } +const resolveConfigUrl = (key: string): string => +{ + try + { + const raw = GetConfiguration().getValue(key, ''); + if(!raw) return ''; + + const interpolated = GetConfiguration().interpolate(raw) || raw; + return interpolated; + } + catch + { + return ''; + } +}; + +const resolveConfigString = (key: string, fallback = ''): string => +{ + try + { + const raw = GetConfiguration().getValue(key, ''); + if(!raw) return fallback; + return raw; + } + catch + { + return fallback; + } +}; + export const LoadingView: FC = props => { - const { isError = false, message = '', homeUrl = '' } = props; + const { isError = false, message = '', homeUrl = '', progress, currentTask = '' } = props; + + const customLogoUrl = useMemo(() => resolveConfigUrl('loading.logo.url'), []); + const customBackground = useMemo(() => resolveConfigString('loading.background', ''), []); + const progressBarColor = useMemo(() => resolveConfigString('loading.progress.color', 'linear-gradient(90deg,#4f8cff,#2563eb)'), []); + + const clampedProgress = typeof progress === 'number' && Number.isFinite(progress) + ? Math.max(0, Math.min(100, Math.round(progress))) + : null; + + const backgroundStyle = customBackground + ? { background: customBackground } + : undefined; + + const backgroundClassName = customBackground + ? 'fixed inset-0 z-[2147483000]' + : 'fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]'; return ( - + + Nitro V3 - { isError && (message && message.length) ? @@ -32,15 +87,59 @@ export const LoadingView: FC = props => } : - - - { message && message.length ? - - { message } - - : null + <> + + + { message && message.length ? + + { message } + + : null + } + + { clampedProgress !== null && + + + + + { clampedProgress }% + + + + { currentTask } + + } - + } From c170eb0a5bcb3af4abf2b898d93e974efc9261a5 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 21 May 2026 00:31:14 +0200 Subject: [PATCH 2/7] fix(types): unblock CI typecheck on Dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pre-existing tsgo failures surfaced by the loading-screen redesign PR: 1. AvatarEffectsView: import loadGamedata via the umbrella `@nitrots/nitro-renderer` instead of `@nitrots/utils`. The deep sub-package alias only exists in vite.config.mjs; tsgo resolves against node_modules, where only the umbrella is symlinked. Same symbol — index.ts re-exports `* from '@nitrots/utils'`. 2. DraggableWindow: `useRef()` -> `useRef(null)`. React 19 typings now require an initial value. Fixed once in a39aa37, re-introduced by the merge in 03bebe4. --- src/common/draggable-window/DraggableWindow.tsx | 2 +- src/components/avatar-effects/AvatarEffectsView.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/common/draggable-window/DraggableWindow.tsx b/src/common/draggable-window/DraggableWindow.tsx index e3b8695..20a17e9 100644 --- a/src/common/draggable-window/DraggableWindow.tsx +++ b/src/common/draggable-window/DraggableWindow.tsx @@ -30,7 +30,7 @@ export const DraggableWindow: FC = props => const [isDragging, setIsDragging] = useState(false); const [isPositioned, setIsPositioned] = useState(false); const [dragHandler, setDragHandler] = useState(null); - const elementRef = useRef(); + const elementRef = useRef(null); const bringToTop = useCallback(() => { let zIndex = 400; for (const existingWindow of CURRENT_WINDOWS) diff --git a/src/components/avatar-effects/AvatarEffectsView.tsx b/src/components/avatar-effects/AvatarEffectsView.tsx index eed77d8..92adb01 100644 --- a/src/components/avatar-effects/AvatarEffectsView.tsx +++ b/src/components/avatar-effects/AvatarEffectsView.tsx @@ -1,5 +1,4 @@ -import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; -import { loadGamedata } from '@nitrots/utils'; +import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, loadGamedata, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa'; import { LocalizeText, SendMessageComposer } from '../../api'; From d762f00c44fb0778332be5b4dc2ab278195030dc Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 21 May 2026 00:35:03 +0200 Subject: [PATCH 3/7] test(catalog): add getNodesByOfferId to useCatalogActions contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useCatalogActions` filter (useCatalog.ts:1042-1043) destructures and returns `getNodesByOfferId` from the store along with the other action methods. The filter contract test was stale — it asserted 11 keys while the actual filter returns 12. Add `getNodesByOfferId` to the expected keys list and to the fakeStore mock so the assertion matches the live hook output. --- src/hooks/catalog/useCatalog.filters.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/catalog/useCatalog.filters.test.tsx b/src/hooks/catalog/useCatalog.filters.test.tsx index 59b6446..aa257d9d 100644 --- a/src/hooks/catalog/useCatalog.filters.test.tsx +++ b/src/hooks/catalog/useCatalog.filters.test.tsx @@ -63,6 +63,7 @@ const { fakeStore } = vi.hoisted(() => selectCatalogOffer: vi.fn(), getNodeById: vi.fn(), getNodeByName: vi.fn(), + getNodesByOfferId: vi.fn(), getBuilderFurniPlaceableStatus: vi.fn() }; @@ -142,6 +143,7 @@ describe('useCatalog filter contract', () => 'getBuilderFurniPlaceableStatus', 'getNodeById', 'getNodeByName', + 'getNodesByOfferId', 'openCatalogByType', 'openPageById', 'openPageByName', From 9e38de6160530ab016d97602ad4cce1d02f1e4c7 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 21 May 2026 01:01:06 +0200 Subject: [PATCH 4/7] feat(auth): capture remember-token from URL and persist for reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CMS Inertia /client page now passes `&token=&token_exp=` on the iframe src so Nitro can persist the token to localStorage on first boot. `App.tsx::prepare()` reads them from `window.location.search` and calls `SetRememberLogin({ token, expiresAt })` when no remember-login is already stored. This wires up the existing reconnect flow: when the WS drops, the loop in `tryRememberLogin()` (already in this file) POSTs the saved token to `login.remember.endpoint` (defaults to `${api.url}/api/auth/remember`) and uses the returned fresh SSO ticket to reconnect. Without this step the localStorage stayed empty and the reconnect always fell through to "Session expired" after a few retries because Arcturus clears `auth_ticket` on first consume. Server side: the CMS counterpart is in medievalshell/InertiaCMS commit on djoohotel — adds the /api/auth/remember endpoint backed by `users_remember_families` (UUID family + 30-day expiry + revoked flag). --- src/App.tsx | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 6bf4d4b..fe05be2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'; -import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api'; +import { ClearRememberLogin, GetRememberLogin, GetUIVersion, SetRememberLogin, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api'; import { Base } from './common'; import { LoadingView } from './components/loading/LoadingView'; import { LoginView } from './components/login/LoginView'; @@ -495,6 +495,31 @@ export const App: FC<{}> = props => let ssoTicket = window.NitroConfig['sso.ticket']; if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket); + + // Cattura il remember-token passato via URL (?token=&token_exp=) + // dal CMS Inertia /client e salvalo in localStorage. Serve a + // tryRememberLogin() in reconnect: chiama POST /api/auth/remember + // col token UUID, riceve un nuovo SSO ticket fresco invece di + // riusare quello cleared da Arcturus dopo il primo consume. + try + { + const urlParams = new URLSearchParams(window.location.search); + const tokenParam = urlParams.get('token'); + const tokenExpParam = urlParams.get('token_exp'); + if(tokenParam && !GetRememberLogin()) + { + const parsedExpiry = Number(tokenExpParam || 0); + const expiresAt = (Number.isFinite(parsedExpiry) && parsedExpiry > 0) + ? parsedExpiry + : Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60); + SetRememberLogin({ token: tokenParam, expiresAt }); + } + } + catch(e) + { + console.warn('[App] failed to persist remember token from URL', e); + } + bumpProgress(10, taskLabel('loading.task.session', 'Verifica sessione')); if(!ssoTicket || ssoTicket === '') From 0c7814fe0480925cffd093133458ff01810af75e Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 21 May 2026 02:03:38 +0200 Subject: [PATCH 5/7] perf(build): granular code-split + preconnect hint for cold-load speed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendor chunk was a single ~1MB blob (react + tanstack-query + framer-motion + jodit + emoji-mart + react-icons + howler + zustand + json5 all merged), forcing every cold load to wait on the slowest of those modules before the page could interactivate. Split it into domain-specific chunks so HTTP/2 multiplexing can pull them in parallel and CF can cache each independently: - vendor-pixi (pixi.js + pixi-filters — when rollup actually splits; currently inlined into the umbrella renderer chunk because nitro-renderer is its sole importer) - vendor-audio (howler) - vendor-emoji (@emoji-mart — heaviest at ~430KB, only used in chat so a longer-term win is making it lazy) - vendor-editor (jodit + @react-page — admin-only news editor) - vendor-react (react / react-dom / scheduler / error-boundary) - vendor-motion / vendor-query / vendor-icons / vendor-state / vendor-json5 - nitro-renderer-{avatar,communication,room,assets} — heaviest renderer packages get their own chunks when imported directly (the umbrella @nitrots/nitro-renderer still hosts the rest) Also add a `` for challenges.cloudflare.com so the Turnstile JS handshake doesn't pay an extra TLS round-trip on the first paint. Net effect: roughly the same total bytes shipped on a cold load, but they fetch in parallel instead of sequentially, and a warm second visitor only re-downloads the chunks whose code actually changed. --- index.html | 3 +++ vite.config.mjs | 40 ++++++++++++++++++++++++++++++++++------ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/index.html b/index.html index 48e1e0a..b162833 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,9 @@ Nitro + + diff --git a/vite.config.mjs b/vite.config.mjs index 1b6899f..e51a1a0 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -164,18 +164,46 @@ export default defineConfig({ rollupOptions: { output: { assetFileNames: 'src/assets/[name]-[hash].[ext]', + // Granular chunking: split the monolithic vendor / nitro-renderer + // bundles into smaller chunks so the browser can fetch them in + // parallel and CF can cache each independently. Splits chosen + // by size impact (pixi ~600KB, react ~150KB, framer-motion ~100KB, + // jodit ~250KB lazy-loaded only by admin news, etc.). manualChunks: id => { - // Renderer source is consumed via filesystem alias - // (../Nitro_Render_V3/packages/*/src) so it is NOT - // under node_modules — needs its own branch before - // the node_modules check. - if(id.includes('Nitro_Render_V3') || id.includes(`${ rendererRoot }`)) return 'nitro-renderer'; + // Vendor checks first — pixi.js/howler are aliased to + // ../Nitro_Render_V3/node_modules so they match + // `Nitro_Render_V3` too. Without this priority, they end + // up bundled into nitro-renderer instead of getting their + // own chunks (pixi alone is ~600KB). Use `/pixi.js/` to + // avoid matching path fragments like `assets/pixi.js/`. + const norm = id.replace(/\\/g, '/'); + if(norm.includes('pixi.js') || norm.includes('pixi-filters')) return 'vendor-pixi'; + if(norm.includes('howler')) return 'vendor-audio'; + if(norm.includes('@emoji-mart')) return 'vendor-emoji'; + if(norm.includes('jodit') || norm.includes('@react-page')) return 'vendor-editor'; + + if(id.includes('Nitro_Render_V3') || id.includes(`${ rendererRoot }`)) + { + // Heaviest renderer packages get their own chunks so + // pages that don't touch them (login flow, very early + // boot) don't have to pay for them upfront. + if(id.includes('/packages/avatar/')) return 'nitro-renderer-avatar'; + if(id.includes('/packages/communication/')) return 'nitro-renderer-comm'; + if(id.includes('/packages/room/')) return 'nitro-renderer-room'; + if(id.includes('/packages/assets/')) return 'nitro-renderer-assets'; + return 'nitro-renderer'; + } if(id.includes('node_modules')) { if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3')) return 'nitro-renderer'; - + if(id.match(/\/react(-dom)?\/|\/scheduler\//) || id.includes('react-error-boundary')) return 'vendor-react'; + if(id.includes('framer-motion')) return 'vendor-motion'; + if(id.includes('@tanstack')) return 'vendor-query'; + if(id.includes('zustand') || id.includes('use-between')) return 'vendor-state'; + if(id.includes('react-icons')) return 'vendor-icons'; + if(id.includes('json5')) return 'vendor-json5'; return 'vendor'; } } From 450b0fface2f0f51bdcc8641133879ff3b8fe6bb Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 21 May 2026 02:23:56 +0200 Subject: [PATCH 6/7] security: don't dump the SSO ticket in the prepare() diagnostic log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `[App] prepare() start` console.warn was including the full SSO ticket from `window.location.search`. SSO tickets are one-shot bearer credentials — any leak (copied logs in a bug report, screen share, malicious browser extension reading console output) grants single-use access to the user's session. Replace the actual ticket with a boolean. --- src/App.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index fe05be2..97fd260 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -477,11 +477,16 @@ export const App: FC<{}> = props => { const prepare = async (width: number, height: number) => { + // Don't dump the actual SSO ticket — it's a one-shot bearer + // credential that grants access to the user's session, so + // logging it in console.warn would leak it via copied logs + // / screen shares / browser extension hooks. Boolean flag is + // enough for the diagnostic. console.warn('[App] prepare() start', { hasNitroConfig: !!window.NitroConfig, ssoTicketInConfig: !!window.NitroConfig?.['sso.ticket'], hasRememberLocal: !!GetRememberLogin(), - urlSso: new URLSearchParams(window.location.search).get('sso') + hasUrlSso: !!new URLSearchParams(window.location.search).get('sso') }); const bootLabel = taskLabel('loading.task.boot', 'Avvio in corso...'); From 35f16a674508c203d3a0cf4116e2c54c30d99da1 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 21 May 2026 02:37:12 +0200 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20PERFORMANCE.md=20=E2=80=94=20client?= =?UTF-8?q?=20+=20server=20recipe=20for=20the=204s=20cold=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone performance guide for the Nitro V3 client, covering both sides of the cold-load story so a deployer doesn't have to cross- reference two repos to get from 60-90 s down to 4 s. Sections: 1. Three Nitro-side changes that matter (code split, LoadingView with real progress, remember-token URL capture) 2. Vite manualChunks — why vendor-first ordering matters, why pixi stays inlined, expected chunk sizes after yarn build 3. LoadingView state model + the 12-stage progress table + the pre-React shell template in scripts/write-asset-loader.mjs 4. Remember-token capture from URL → SetRememberLogin, with DevTools verification of nitro.auth.remember localStorage entry 5. nginx gzip (the single biggest win at ~17x for JSON5) + 30-day cache headers on gamedata + try_files manifest fallback 6. Windows + IIS equivalent — URL Rewrite + ARR reverse proxy to Node, Dynamic Compression toggle, web.config snippets, trade-offs (CPU cost, JDBC quirks, shared hosting caveat) 7. End-to-end verification probes — chunks present, gzip on, dir fallback works, progress bar renders, remember-token persisted Cross-references medievalshell/InertiaCMS:docs/PERFORMANCE.md for the matching CMS-side application config (SSO TTL, /api/auth/remember endpoint, migrations). --- docs/PERFORMANCE.md | 586 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 586 insertions(+) create mode 100644 docs/PERFORMANCE.md diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..54f5702 --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,586 @@ +# Nitro V3 — Cold-load performance + +Practical recipe to take a Nitro V3 cold load from the typical +60-90 s (and intermittent "Session expired") baseline down to ~4 s. +The wins compound: each section below has measurable impact, in +roughly the order of cost vs benefit. + +Three things matter on the client (this repo): granular code split, +a real progress bar driven by boot stages, and capturing the +remember-token from the iframe URL on first boot. The other three — +gzip on the static server, long cache on gamedata, and a server +endpoint to mint fresh SSO tickets — are documented further down +under §5 (nginx) and §6 (IIS) plus a quick note in §4 about the +CMS contract. + +--- + +## 1. The three Nitro-side changes that matter + +1. **Granular code split** (`vite.config.mjs`) — a 1 MB vendor bundle + is replaced by ~12 smaller chunks the browser fetches in parallel + via HTTP/2 multiplexing. +2. **Loading screen with a real progress bar** + per-stage labels + (`src/components/loading/LoadingView.tsx`, driven by + `src/App.tsx::prepare()`) so a slow boot looks like progress, not + a frozen GIF. +3. **Remember-token capture from URL** (`src/App.tsx::prepare()`) so + that when the WS drops the existing `tryRememberLogin()` round + can hit the CMS `POST /api/auth/remember` endpoint and get a + fresh SSO ticket instead of falling through to "Session expired". + +The server-side wins (gzip, cache, SSO TTL) live outside this repo — +without them this client still loads, but you stay at the 60-90 s +baseline. + +--- + +## 2. Vite `manualChunks` — split the vendor blob + +Default `yarn build` ships: + +- `vendor` ~1 MB (react + tanstack-query + framer-motion + jodit + + emoji-mart + react-icons + howler + zustand + json5 — everything + merged) +- `nitro-renderer` ~2.5 MB (renderer source + pixi.js inlined) +- `src` ~1.7 MB (app code) + +The vendor blob forces the browser to wait on the slowest dependency +before it can hydrate. Split it by domain — see +[`vite.config.mjs`](../vite.config.mjs) for the live version, the +intent is captured below: + +```js +manualChunks: id => { + const norm = id.replace(/\\/g, '/'); + + // Vendors first — pixi.js / howler / emoji-mart / jodit are aliased + // to ../Nitro_Render_V3/node_modules, so they would otherwise be + // swallowed by the `Nitro_Render_V3` branch lower down and pulled + // into the renderer chunk. + if(norm.includes('pixi.js') || norm.includes('pixi-filters')) return 'vendor-pixi'; + if(norm.includes('howler')) return 'vendor-audio'; + if(norm.includes('@emoji-mart')) return 'vendor-emoji'; + if(norm.includes('jodit') || norm.includes('@react-page')) return 'vendor-editor'; + + if(id.includes('Nitro_Render_V3') || id.includes(`${ rendererRoot }`)) { + if(id.includes('/packages/avatar/')) return 'nitro-renderer-avatar'; + if(id.includes('/packages/communication/')) return 'nitro-renderer-comm'; + if(id.includes('/packages/room/')) return 'nitro-renderer-room'; + if(id.includes('/packages/assets/')) return 'nitro-renderer-assets'; + return 'nitro-renderer'; + } + + if(id.includes('node_modules')) { + if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3')) return 'nitro-renderer'; + if(id.match(/\/react(-dom)?\/|\/scheduler\//) || id.includes('react-error-boundary')) return 'vendor-react'; + if(id.includes('framer-motion')) return 'vendor-motion'; + if(id.includes('@tanstack')) return 'vendor-query'; + if(id.includes('zustand') || id.includes('use-between')) return 'vendor-state'; + if(id.includes('react-icons')) return 'vendor-icons'; + if(id.includes('json5')) return 'vendor-json5'; + return 'vendor'; + } +} +``` + +Two practical points the comments don't make obvious: + +- **Vendor checks come first.** Pixi.js, howler, emoji-mart and jodit + are pulled in via an alias to `../Nitro_Render_V3/node_modules`, + so their `id` matches `Nitro_Render_V3`. If the renderer branch + runs before the vendor one, those modules end up bundled into the + renderer chunk instead of their own — defeating the whole point. + +- **Pixi often stays inlined.** Rollup keeps a module in the chunk + of its sole importer, and `pixi.js` is consumed only through the + `@nitrots/nitro-renderer` umbrella. Expect `vendor-pixi` to be + near-empty until something *outside* the renderer also imports + pixi. This is fine — pixi gets the renderer chunk's cache lifetime + anyway. + +Verify after `yarn build`: + +``` +dist/assets/nitro-renderer-*.js ~2.5 MB raw, ~765 KB gzip +dist/assets/vendor-*.js ~12 chunks, 4-430 KB each +dist/assets/src-*.js ~1.7 MB raw, ~550 KB gzip +``` + +If you see a single `vendor-*.js` over 800 KB raw, the chunk +function isn't matching the way you expect — log `id` from inside +`manualChunks` during build to find out what's actually being +handed in. + +Also add the connection hint to [`index.html`](../index.html): + +```html + +``` + +Saves one TLS handshake on cold load — the Turnstile script tag +already loads from that domain. + +--- + +## 3. LoadingView — real progress, real labels + +[`src/components/loading/LoadingView.tsx`](../src/components/loading/LoadingView.tsx) +renders the dark-blue boot screen the user sees before `isReady` +flips. It accepts a `progress` number (0-100) and a `currentTask` +string. The progress bar is hidden when `progress` is `undefined` +(error / Suspense fallback path) and animates between updates. + +The state lives in [`src/App.tsx`](../src/App.tsx): + +```ts +const [ loadingProgress, setLoadingProgress ] = useState(0); +const [ loadingTask, setLoadingTask ] = useState(''); + +const taskLabel = useCallback((key: string, fallback: string): string => { + // … reads from renderer-config so the strings are translatable +}); + +const bumpProgress = useCallback((value: number, task?: string) => { + setLoadingProgress(prev => (value > prev ? value : prev)); + if(task !== undefined) setLoadingTask(task); +}, []); +``` + +`prepare()` bumps the progress through 12 stages as it goes: + +| % | Stage | Default label | +|---|---|---| +| 5 | App start | `Avvio in corso...` | +| 10 | NitroConfig validated | `Verifica sessione` | +| 20 | Renderer constructed | `Inizializzazione renderer` | +| 25 | Config init done | `Caricamento contenuti...` | +| 36, 47, 58, 70 | each warmup task resolves | per-task (`Sto caricando il guardaroba`, …) | +| 78 | `GetSessionDataManager().init()` done | `Caricamento dati utente` | +| 85 | `GetRoomSessionManager().init()` done | `Caricamento stanze` | +| 92 | `GetRoomEngine().init()` done | `Caricamento engine grafico` | +| 98 | `GetCommunication().init()` done | `Connessione al server` | +| 100 | `setIsReady(true)` about to fire | `Pronto!` | + +The labels are config-driven — `taskLabel('loading.task.boot', 'Avvio in corso...')` +reads `loading.task.*` keys from the renderer-config and falls back +to the Italian baseline if unset. To localise, add the keys to +`public/configuration/renderer-config.json` (see the `.example` +file for the full list). + +Logo and background are also configurable via the same mechanism — +`loading.logo.url`, `loading.background`, `loading.progress.color`. +Leaving them empty keeps the shipped dark-blue radial + Nitro V3 +logo top-left. + +### 3.1 The pre-React shell (asset-loader.js) + +There is a second, tiny loading screen that the asset loader writes +into `#root` *before* React mounts. It used to be a light-blue +login-skeleton with two grey rectangles — visible for ~200 ms before +React took over, producing a hated flash. The template lives in +[`scripts/write-asset-loader.mjs`](../scripts/write-asset-loader.mjs) +(`renderShell`) and now paints the same `radial-gradient(#1d1a24,#003a6b)` +as the React `LoadingView`, so the handoff is invisible. + +Don't hand-edit `public/configuration/asset-loader.js` — the +`prebuild` hook regenerates it from the template every `yarn build`. + +--- + +## 4. Remember-token capture — making reconnect work + +Arcturus clears `auth_ticket` to `''` the moment it consumes an SSO +ticket. Without a remember-token the client retries reconnect with +the same (now empty) ticket and falls through to "Session expired" +after 2-7 attempts. + +The CMS issues a UUID family token when it serves `/client`, and +passes it on the iframe URL as `&token=&token_exp=`. Nitro +captures it on first boot: + +```ts +// src/App.tsx::prepare() +try { + const urlParams = new URLSearchParams(window.location.search); + const tokenParam = urlParams.get('token'); + const tokenExpParam = urlParams.get('token_exp'); + if(tokenParam && !GetRememberLogin()) { + const parsedExpiry = Number(tokenExpParam || 0); + const expiresAt = (Number.isFinite(parsedExpiry) && parsedExpiry > 0) + ? parsedExpiry + : Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60); + SetRememberLogin({ token: tokenParam, expiresAt }); + } +} catch(e) { + console.warn('[App] failed to persist remember token from URL', e); +} +``` + +The capture is guarded by `!GetRememberLogin()` — if the user has +visited before, the stored token wins and the URL one is ignored. + +Once stored, the existing `tryRememberLogin()` machinery picks it up +on every reconnect: it POSTs to +`${api.url}/api/auth/remember` (configurable via the +`login.remember.endpoint` renderer-config key), receives a fresh +SSO ticket back, and rotates the connection. See the CMS doc for the +server endpoint's contract. + +Verify the stored token in browser DevTools: + +``` +Application → Local Storage → https:// + Key: nitro.auth.remember + Value: {"token":"","expiresAt":1781912345,"username":""} +``` + +If `nitro.auth.remember` is missing after a successful first load, +the CMS isn't passing `token=` on the iframe URL. Check +`AuthController.client` on the CMS side. + +--- + +## 5. Server-side: nginx gzip + long cache (the single biggest win) + +The Nitro client ships ~4.3 MB raw across the main bundle, renderer +chunk and vendor splits. If the server doesn't compress and doesn't +let the browser cache, every visitor pays the full price on every +load — that's exactly the 60-90 s baseline you avoid by configuring +nginx properly. + +### 5.1 Enable gzip globally + +Default nginx ships with the `gzip` block commented out. Edit +`/etc/nginx/nginx.conf` and replace the `#gzip on;` line inside the +`http {}` block: + +```nginx +gzip on; +gzip_vary on; +gzip_proxied any; +gzip_comp_level 6; +gzip_min_length 1024; +gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/x-javascript + application/json + application/xml + application/rss+xml + application/atom+xml + image/svg+xml + font/ttf + font/otf + application/font-woff + application/vnd.ms-fontobject; +``` + +Back up the file before editing, then reload: + +```bash +cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak-$(date +%Y%m%d-%H%M%S) +nginx -t # validate syntax first +systemctl reload nginx +``` + +The impact is *enormous* — `palettes.json5` drops from 330 KB to 18 KB +on the wire (~17×), and the renderer JS bundle from 2.5 MB to 765 KB +(~3.3×). Verify: + +```bash +curl -sI -H 'Accept-Encoding: gzip' \ + 'https:///nitro/assets/nitro-renderer-XXXXX.js' \ + | grep -i 'content-encoding' +# expected: content-encoding: gzip +``` + +If you forget `application/json` from `gzip_types` you lose the +gamedata compression — that's the one that matters the most because +the gamedata files are by far the heaviest payload. + +### 5.2 Long Cache-Control on gamedata + +Inside the `/nitro-assets/` or `/nitro-assets/` location +block, the gamedata `.json5` files deserve a 30-day cache because +they only change on deploy: + +```nginx +location /nitro-assets/ { + alias /var/www/cmsjs/public/nitro-assets/; + try_files $uri ${uri}manifest.json5 ${uri}manifest.json =404; + autoindex off; + default_type application/json; + expires 7d; + add_header Cache-Control "public, max-age=604800, immutable"; + + location ~ \.json5?$ { + types {} default_type application/json; + expires 30d; + add_header Cache-Control "public, max-age=2592000"; + } +} +``` + +The outer 7-day cache covers PNG / nitro / mp3 files. The inner +location block raises the JSON5 lifetime to 30 days because the +content is effectively immutable per deploy. Cloudflare honours +`Last-Modified` so revalidation still works — you don't need to +cache-bust by filename. + +For the JS / CSS chunks the filenames are content-hashed by Vite, so +a long cache is safe — apply the same `Cache-Control: max-age=2592000` +to the `/nitro/assets/` location. + +### 5.3 The `try_files → manifest.json5` fallback + +`loadGamedata(url)` in the renderer SDK can be pointed at either a +single JSON file or a directory containing `manifest.json5` + tier +sub-directories. The directory pattern is what we use in production, +so requests like `/nitro-assets/gamedata/figuremap/` (note the +trailing slash) need to resolve to the directory's manifest. + +The `try_files $uri ${uri}manifest.json5 ${uri}manifest.json =404;` +above does exactly that — try the URI as-is, fall back to the +`manifest.json5` inside the directory, fall back to `.json` for +legacy deploys, then 404. Without it nginx returns 403 (autoindex +off) on directory URLs and the loader cascades into the manifest 404 +path. + +--- + +## 6. Server-side: Windows + IIS deployment + +You can reach the same 4 s cold load on Windows Server with IIS. The +same three wins (gzip, long cache, JSON5 fallback) are replicable — +syntax changes, performance ceiling doesn't. + +### 6.1 Don't host Node inside IIS + +`IISNode` is unmaintained. The current MS recommendation is to run +Node as a Windows service and let IIS reverse-proxy to it: + +1. Install Node 22 LTS, run the CMS app as a Windows Service (via + `nssm`, `pm2-windows-startup`, or a scheduled task on boot) bound + to `127.0.0.1:3003` — same layout as `docker-compose.yml` on the + Linux host. +2. Install Arcturus separately as a Windows service running + `Habbo-x.y.z-jar-with-dependencies.jar` against MariaDB. WS ports + 30001 + 30002 stay on `127.0.0.1`. +3. IIS handles HTTPS termination, static file serving, compression + and reverse-proxying `/api/*` + `/client` + the Inertia entry + point to `127.0.0.1:3003`. + +Install these IIS features (Server Manager → Web Server → Add Roles +& Features): + +- **URL Rewrite** — proxy rules +- **Application Request Routing (ARR)** — lets IIS act as a forward + proxy; *enable proxy in the ARR feature page* after install +- **WebSocket Protocol** — required for the Arcturus WS upgrade +- **Static Content** + **Static Content Compression** +- **Dynamic Content Compression** — **off by default**, this is the + single most important toggle on a vanilla Windows Server + +### 6.2 Enable compression site-wide + +IIS Manager → site → **Compression** feature → tick *both* +"Enable dynamic content compression" and "Enable static content +compression". Equivalent of nginx's `gzip on;`. + +Without ticking both you ship raw bytes. Static covers JS / CSS / +JSON files, Dynamic covers Node responses (HTML from the Inertia +render). Add `application/json` to the compressor (and `.json5` to +its MIME map) in `applicationHost.config` or the site's `web.config`: + +```xml + + + + + + + + + + + + + + + + + + + + + + + +``` + +Verify with PowerShell: + +```powershell +Invoke-WebRequest -Uri 'https:///nitro-assets/gamedata/figuredata/core/palettes.json5' ` + -Headers @{ 'Accept-Encoding' = 'gzip' } ` + -MaximumRedirection 0 | Select-Object -ExpandProperty Headers +# expected: Content-Encoding = gzip +``` + +### 6.3 Long cache for gamedata + +Drop a `web.config` inside the `nitro-assets/` virtual +directory (or nest under ``): + +```xml + + + + + + + + + +``` + +`30.00:00:00` is the IIS TimeSpan for 30 days — same effect as +`Cache-Control: public, max-age=2592000` on nginx. + +Set a separate, shorter cache (e.g. 5 minutes) on `index.html` so +deploys propagate without forcing visitors to clear their cache. + +### 6.4 Directory → manifest.json5 fallback + +nginx's `try_files $uri ${uri}manifest.json5 ${uri}manifest.json =404;` +has no native IIS equivalent. Use **URL Rewrite** to chain two rules +inside the same ``: + +```xml + + + + + + + + + + + + + + + + + + + + +``` + +### 6.5 Reverse proxy to Node + WebSocket upgrade + +Once ARR is installed and proxy enabled (IIS Manager → server node → +ARR → Server Proxy Settings → check "Enable proxy"), add a top-level +rule that forwards everything *not* matching a static file: + +```xml + + + + + + + + + + + + + + + + + + + + + + + +``` + +ARR transparently handles the WebSocket upgrade once the WebSocket +Protocol IIS feature is installed. + +### 6.6 IIS trade-offs (honest) + +- **Compression CPU**: IIS dynamic compression is more CPU-hungry than + nginx's worker-pool gzip. On 2-vCPU droplets expect ~10-15 % extra + CPU during peak concurrency. +- **Docker overhead**: Docker Desktop on Windows goes through the + WSL2 file-system bridge. Bind-mounting Linux-style paths into a + container is measurably slower than the same on a native Linux + host. Recommendation: run Node + Arcturus as native Windows + services, *not* containerised. +- **Java JDBC on Windows**: Arcturus's JDBC pool exhibits slightly + higher lock-wait under concurrent room load on Windows than on + Linux. Re-tune `db.pool.maxsize` if you saturate. + +Browser-perceived performance is identical to nginx once the config +above is in place. The 4 s cold-load target is achievable on any +Windows Server 2019 / 2022 box. + +The one deployment to **avoid**: shared Windows hosting where the +hoster doesn't let you enable Dynamic Compression at the application +host level. You stay stuck at the 60-90 s baseline because neither +Node's gzip nor IIS's compressor can be turned on. + +--- + +## 7. End-to-end verification + +Run each probe in order — they walk the request through every layer +covered above. A green light on all four means the cold load is +correctly tuned. + +```bash +# 1. Build artefact has the granular chunks +yarn build +ls dist/assets/ | grep -E '^(vendor|nitro-renderer)-' | wc -l +# expected: ~12-14 chunks + +# 2. Server is compressing JSON5 (or JS — pick either) +curl -sI -H 'Accept-Encoding: gzip' \ + 'https:///nitro-assets/gamedata/figuredata/core/palettes.json5' \ + | grep -iE 'content-encoding|cache-control' +# expected: +# content-encoding: gzip +# cache-control: public, max-age=2592000 + +# 3. Directory → manifest.json5 fallback +curl -sI 'https:///nitro-assets/gamedata/figuremap/' \ + | head -1 +# expected: HTTP/2 200 (not 403 or 404) + +# 4. LoadingView renders the progress bar — easiest from the live site: +# DevTools → Performance → Record → reload /client +# Look for the progress bar transitioning 5→100% within 4s on a +# warm-cache load, ~10-20s on a cold one with empty CF cache. + +# 5. Remember-token captured to localStorage: +# DevTools → Application → Local Storage → check nitro.auth.remember +# is populated after the first successful load. +``` + +If the build artefact is correct but the live site doesn't pick up +the new chunks, the deploy didn't replace `dist/` on the server. +Wipe the target dir's `assets/*.js` and `src/assets/*.css` before +extracting the new tarball — old chunk filenames stick around +otherwise.