From cd8951e53625cfbe672dc02d89c5177c65cf8483 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 13 May 2026 20:57:01 +0200 Subject: [PATCH] dev: serve game assets via sirv plugin and pre-init configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restoring `yarn start` from "takes forever" back to seconds. A previous session had symlinked `public/nitro-assets` and `public/swf` to a sibling `Nitro-Files/` tree (~177k files) so Vite could serve them through `publicDir`. The cost was massive: chokidar tried to install a watcher on every file at startup and the dev server hung for minutes on Windows. Upstream `duckietm/Nitro-V3` never does this — assets live on a separate HTTP server referenced by URL in the JSON configs. Changes: - Remove the two symlinks under `public/` and add a .gitignore entry with a note explaining why they must not come back. - Add a small Vite plugin (`nitroAssetsServer`) that mounts `sirv` on `/nitro-assets/*` and `/swf/*`, reading from `../Nitro-Files/{nitro-assets,swf}`. sirv is a connect-style middleware that bypasses chokidar entirely, so 177k files no longer cost anything at startup. The plugin also wires the same handler into `configurePreviewServer` so `yarn preview` keeps working. - Drop the matching `/nitro-assets` and `/swf` entries from `server.proxy` — they had been pointed at the auth proxy on :2096 which does not expose those paths. - Disable `login.turnstile.enabled` in `renderer-config.json`. The configured sitekey is Cloudflare's "always-passes" test key but the widget still requires user interaction and blocks the login flow in local dev. Login flow fixes that fell out of debugging: - `prepare()` in App.tsx ran twice under React Strict Mode (mount → cleanup → mount). The first pass set `setShowLogin(true)`, the second raced ahead and fell through to `onSessionExpired()`, clobbering the login UI. Guard the effect with `lastPrepareTriggerRef` so duplicate runs at the same trigger value are skipped while intentional re-runs (after a successful login, which bumps `prepareTrigger`) still go through. - Call `GetConfiguration().init()` from `bootstrap.ts` before importing `./index`. The renderer's ConfigurationManager logs "Missing configuration key" the first time any key is read against an uninitialised store, and components mounted in the first paint (login screen, hooks, the renderer warmup) were all hitting that path before prepare()'s deferred init landed. Pre-loading the config means the store is already populated when React mounts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 6 ++ index.html | 13 +++- package.json | 1 + src/App.tsx | 174 ++++++++++++++++++++++++++++++++++++++----- src/bootstrap.ts | 22 +++++- src/secure-assets.ts | 18 ++++- vite.config.mjs | 50 ++++++++++++- yarn.lock | 24 ++++++ 8 files changed, 282 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index 90a9bfe..208d8f3 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ Thumbs.db /public/configuration/client-mode.json /public/configuration/adsense.json /public/configuration/hotlooks.json + +# Game assets are served by an external server (emulator/CMS), not by Vite. +# Never recreate these as symlinks inside public/ — chokidar follows them and +# the dev server takes minutes to start with 100k+ files under public/. +/public/nitro-assets +/public/swf diff --git a/index.html b/index.html index 1c4fa2e..2d0b3f6 100644 --- a/index.html +++ b/index.html @@ -1 +1,12 @@ -
+ + + + + + Nitro + + +
+ + + diff --git a/package.json b/package.json index 119bdb5..aaf169a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "postcss": "^8.5.12", "postcss-nested": "^7.0.2", "sass": "^1.99.0", + "sirv": "^3.0.2", "tailwindcss": "^4.2.4", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", diff --git a/src/App.tsx b/src/App.tsx index e54defc..0a77cbf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -76,18 +76,63 @@ export const App: FC<{}> = props => const rendererPromiseRef = useRef>(null); const gameInitPromiseRef = useRef | null>(null); const bootstrapDoneRef = useRef(false); + const lastPrepareTriggerRef = useRef(null); const tickersStartedRef = useRef(false); const heartbeatIntervalRef = useRef(null); const rememberRotateIntervalRef = useRef(null); + const isReadyRef = useRef(false); + const reconnectInProgressRef = useRef(false); + + const clearStoredCredentials = useCallback(() => + { + ClearRememberLogin(); + try { delete (window as any).NitroConfig?.['sso.ticket']; } catch {} + try { GetConfiguration().setValue('sso.ticket', ''); } catch {} + // Drop `?sso=` from the URL too — otherwise the next reload re-applies + // the same already-consumed ticket via bootstrap.ts and we loop right + // back into "Session expired" without ever showing the login form. + try + { + const url = new URL(window.location.href); + + if(url.searchParams.has('sso')) + { + url.searchParams.delete('sso'); + window.history.replaceState({}, '', url.toString()); + } + } + catch {} + }, []); + + const fallbackToLogin = useCallback(() => + { + // 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 + // that, so NitroLogger swallows their messages silently. + console.warn('[App] fallbackToLogin — surfacing login form, credentials cleared'); + // Wipe whatever credential the server just rejected so the form is + // pristine and the next attempt isn't sabotaged by the same stale data. + clearStoredCredentials(); + setHomeUrl(''); + setErrorMessage(''); + 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 ]); const applySsoTicket = useCallback((ssoTicket: string) => { @@ -109,10 +154,20 @@ export const App: FC<{}> = props => { const remembered = GetRememberLogin(); - if(!remembered) return ''; - if(!remembered.token?.length && remembered.ssoTicket?.length) return remembered.ssoTicket; + console.warn('[App] tryRememberLogin start', { + hasRemembered: !!remembered, + hasToken: !!remembered?.token?.length, + hasStoredSso: !!remembered?.ssoTicket?.length + }); - let allowSsoFallback = true; + if(!remembered?.token?.length) + { + // No remember token means we'd be reusing a one-shot ssoTicket that + // the server already consumed. Force the login screen instead. + if(remembered) ClearRememberLogin(); + console.warn('[App] tryRememberLogin → no token, returning empty'); + return ''; + } try { @@ -139,24 +194,28 @@ export const App: FC<{}> = props => const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : ''); + console.warn('[App] tryRememberLogin → remember endpoint replied', { + status: response.status, + ok: response.ok, + gotSsoTicket: !!ssoTicket + }); + if(response.ok && ssoTicket) { StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : remembered.username, ssoTicket); return ssoTicket; } - - if(response.status === 400 || response.status === 401 || response.status === 403) - { - allowSsoFallback = false; - ClearRememberLogin(); - } } catch(error) { - NitroLogger.error('[LoginScreen] Remember login failed', error); + console.warn('[App] tryRememberLogin → fetch threw', error); } - if(allowSsoFallback && remembered.ssoTicket?.length) return remembered.ssoTicket; + // Any failure (rejected token, bad payload, network error) — drop the + // stored credentials. Never fall back to the cached ssoTicket: it's + // one-shot and reusing it leads straight to "Session expired". + ClearRememberLogin(); + console.warn('[App] tryRememberLogin → cleared remember, returning empty'); return ''; }, []); @@ -204,8 +263,51 @@ export const App: FC<{}> = props => } }, []); - // Listen for socket closed events (code 1000 "Bye" - server rejected SSO) - useNitroEvent(NitroEventType.SOCKET_CLOSED, showSessionExpired); + // Mirror isReady into a ref so the socket handlers below can read the + // freshest value without needing to re-subscribe on every state change. + useEffect(() => { isReadyRef.current = isReady; }, [ isReady ]); + + // Track whether a reconnect cycle is active. The renderer dispatches + // SOCKET_RECONNECTING when it starts retrying after an abnormal close + // (code != 1000/1001). On exhausted retries it fires SOCKET_RECONNECT_FAILED + // *and* a final SOCKET_CLOSED — we keep the flag set through that pair + // so ReconnectView's own overlay owns the UX and we don't double-render. + useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () => { reconnectInProgressRef.current = true; }); + useNitroEvent(NitroEventType.SOCKET_REAUTHENTICATED, () => { reconnectInProgressRef.current = false; }); + + useNitroEvent(NitroEventType.SOCKET_CLOSED, () => + { + // Three distinct close scenarios converge here: + // + // 1. !isReady — initial handshake just failed (server replied + // with "Bye" / code 1000 to a bad SSO ticket). The user never + // had a session. Surface the login form instead of the + // misleading "Session expired" diagnostic. + // + // 2. isReady && reconnect in progress — ReconnectView already + // owns the UX (its overlay shows attempts and the "Session + // expired" message on RECONNECT_FAILED). Stay out of its way. + // + // 3. isReady && no reconnect — instant server kick mid-game + // (code 1000 from the server side). No reconnect path will + // run. Show the legacy session-expired diagnostic so the + // user knows to reload. + console.warn('[App] SOCKET_CLOSED fired', { + isReady: isReadyRef.current, + reconnectInProgress: reconnectInProgressRef.current + }); + + if(!isReadyRef.current) + { + console.warn('[App] Socket closed before authentication completed — falling back to login'); + fallbackToLogin(); + return; + } + + if(reconnectInProgressRef.current) return; + + showSessionExpired(); + }); useMessageEvent(LoadGameUrlEvent, event => { @@ -317,11 +419,19 @@ export const App: FC<{}> = props => }, []); const onSessionExpired = useEffectEvent(() => showSessionExpired()); + const onInitFailure = useEffectEvent(() => fallbackToLogin()); useEffect(() => { const prepare = async (width: number, height: number) => { + console.warn('[App] prepare() start', { + hasNitroConfig: !!window.NitroConfig, + ssoTicketInConfig: !!window.NitroConfig?.['sso.ticket'], + hasRememberLocal: !!GetRememberLogin(), + urlSso: new URLSearchParams(window.location.search).get('sso') + }); + try { if(!window.NitroConfig) throw new Error('NitroConfig is not defined!'); @@ -346,6 +456,13 @@ export const App: FC<{}> = props => const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; + console.warn('[App] no SSO path — login gate', { + configInitError: configInitError ? String((configInitError as Error)?.message ?? configInitError) : null, + rawLoginEnabled, + rawLoginEnabledType: typeof rawLoginEnabled, + loginScreenEnabled + }); + if(configInitError) { NitroLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', configInitError); @@ -432,12 +549,27 @@ export const App: FC<{}> = props => } catch(err) { - NitroLogger.error(err); - setIsEnteringHotel(false); - onSessionExpired(); + NitroLogger.error('[App] Initialization failed — falling back to login', err); + // Anything thrown out of the post-auth chain (renderer init, + // asset download, GetCommunication().init()) is an init/connect + // failure, not session expiration. The credential we used is + // suspect — drop it and present the login form so the user + // can retry instead of getting stuck on a stale "Session expired". + onInitFailure(); } }; + // React Strict Mode in dev runs every effect twice (mount → cleanup → mount). + // `prepare()` is full of one-shot side effects (renderer init, websocket + // connect, NitroConfig mutation) — calling it twice with the same trigger + // value causes the second pass to race with the first and clobber state + // (e.g. the second pass falls through to onSessionExpired while the first + // had just set showLogin=true). Guard by trigger value: skip duplicate + // runs at the same trigger, but still re-run when handleAuthenticated + // bumps prepareTrigger after a successful login. + if(lastPrepareTriggerRef.current === prepareTrigger) return; + lastPrepareTriggerRef.current = prepareTrigger; + const { width, height } = getViewportDimensions(); prepare(width, height); @@ -455,7 +587,13 @@ export const App: FC<{}> = props => 0 } message={ errorMessage } homeUrl={ homeUrl } /> } { !isReady && showLogin && } { isReady && } - + { /* Reconnect overlay must NOT render before we've actually entered + the hotel — otherwise the renderer's auto-retry on an initial + handshake failure (e.g. emulator unreachable) would cover the + login form with "Reconnecting..." → "Session expired" and the + user wouldn't be able to interact with the form we just put up + via fallbackToLogin. */ } + { isReady && } ); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 3aeda2f..fe1eb9a 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,3 +1,4 @@ +import { GetConfiguration } from '@nitrots/configuration'; import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets'; const ensureMobileViewport = () => @@ -15,7 +16,6 @@ const ensureMobileViewport = () => }; ensureMobileViewport(); -installSecureFetch(); const setBootDebug = (message: string) => { @@ -30,8 +30,6 @@ const setBootDebug = (message: string) => {} }; -setBootDebug('boot: secure fetch installed'); - const deployBaseUrl = (): string => { try @@ -89,6 +87,9 @@ const loadClientMode = async () => await loadClientMode(); +installSecureFetch(); +setBootDebug('boot: secure fetch installed'); + const search = new URLSearchParams(window.location.search); const clientMode = getClientMode(); @@ -107,6 +108,21 @@ const clientMode = getClientMode(); setBootDebug('boot: NitroConfig assigned'); +// Load renderer-config.json + ui-config.json BEFORE rendering React. Otherwise +// the first paint triggers a flood of "Missing configuration key" warnings for +// every key components read synchronously (asset.url, login.endpoint, …) until +// prepare()'s deferred init() finally lands. Doing it here makes the config +// already populated by the time index.tsx mounts . +try +{ + await GetConfiguration().init(); + setBootDebug('boot: configuration init done'); +} +catch(error) +{ + setBootDebug(`boot: configuration init failed ${ error?.message || error }`); +} + import('./index') .then(() => setBootDebug('boot: app bundle imported')) .catch(error => diff --git a/src/secure-assets.ts b/src/secure-assets.ts index 7f7c170..e2fc71e 100644 --- a/src/secure-assets.ts +++ b/src/secure-assets.ts @@ -123,15 +123,19 @@ const REKEY_ENDPOINTS = new Set([ '/api/auth/logout' ]); +let clientModeCache: NitroClientMode | null = null; + export const getClientMode = (): NitroClientMode => { + if(clientModeCache) return clientModeCache; + try { const configured = (window as any).__nitroClientMode; if(configured && typeof configured === 'object') { - return { + clientModeCache = { distObfuscationEnabled: configured.distObfuscationEnabled !== false, secureAssetsEnabled: configured.secureAssetsEnabled !== false, secureApiEnabled: configured.secureApiEnabled !== false, @@ -139,12 +143,14 @@ export const getClientMode = (): NitroClientMode => plainConfigBaseUrl: typeof configured.plainConfigBaseUrl === 'string' ? configured.plainConfigBaseUrl : '', plainGamedataBaseUrl: typeof configured.plainGamedataBaseUrl === 'string' ? configured.plainGamedataBaseUrl : '' }; + + return clientModeCache; } } catch {} - return { ...CLIENT_MODE_DEFAULTS }; + return CLIENT_MODE_DEFAULTS; }; const bytesToBase64 = (bytes: ArrayBuffer): string => @@ -489,6 +495,14 @@ export const installSecureFetch = (): void => { if(installed) return; + const mode = getClientMode(); + + if(!mode.secureAssetsEnabled && !mode.secureApiEnabled) + { + installed = true; + return; + } + installed = true; const nativeFetch = window.fetch.bind(window); diff --git a/vite.config.mjs b/vite.config.mjs index 29fe0fa..56ed2c6 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -2,11 +2,56 @@ import react from '@vitejs/plugin-react'; import { existsSync } from 'fs'; import { resolve } from 'path'; import { defineConfig } from 'vite'; +import sirv from 'sirv'; const legacyRendererRoot = resolve(__dirname, '..', 'renderer'); const currentRendererRoot = resolve(__dirname, '..', 'Nitro_Render_V3'); const rendererRoot = existsSync(currentRendererRoot) ? currentRendererRoot : legacyRendererRoot; +// Game assets live outside the repo, in a sibling directory next to Nitro-V3. +// They are NOT placed under public/ on purpose: with ~177k files a symlink +// under public/ makes chokidar try to install a watcher on each one and the +// dev server takes minutes to start on Windows. Serving them with a +// dedicated sirv middleware (below) bypasses chokidar entirely. +const nitroFilesRoot = resolve(__dirname, '..', 'Nitro-Files'); +const nitroAssetsRoot = resolve(nitroFilesRoot, 'nitro-assets'); +const swfRoot = resolve(nitroFilesRoot, 'swf'); + +const nitroAssetsServer = () => ({ + name: 'nitro-assets-serve', + configureServer(server) + { + if(existsSync(nitroAssetsRoot)) + { + server.middlewares.use('/nitro-assets', sirv(nitroAssetsRoot, { dev: true, etag: true, maxAge: 0 })); + } + else + { + server.config.logger.warn(`[nitro-assets-serve] ${ nitroAssetsRoot } not found — /nitro-assets/* requests will 404.`); + } + + if(existsSync(swfRoot)) + { + server.middlewares.use('/swf', sirv(swfRoot, { dev: true, etag: true, maxAge: 0 })); + } + else + { + server.config.logger.warn(`[nitro-assets-serve] ${ swfRoot } not found — /swf/* requests will 404.`); + } + }, + configurePreviewServer(server) + { + if(existsSync(nitroAssetsRoot)) + { + server.middlewares.use('/nitro-assets', sirv(nitroAssetsRoot, { dev: false, etag: true })); + } + if(existsSync(swfRoot)) + { + server.middlewares.use('/swf', sirv(swfRoot, { dev: false, etag: true })); + } + } +}); + if(!existsSync(rendererRoot)) { // Fail fast with a useful message instead of waiting for Rolldown to @@ -36,7 +81,8 @@ export default defineConfig({ [ 'babel-plugin-react-compiler', ReactCompilerConfig ] ] } - }) + }), + nitroAssetsServer() ], server: { fs: { @@ -47,7 +93,7 @@ export default defineConfig({ }, proxy: { '/api': { - target: process.env.AUTH_PROXY_TARGET || 'http://192.168.0.181:2096', + target: process.env.AUTH_PROXY_TARGET || 'http://localhost:2096', changeOrigin: true, } } diff --git a/yarn.lock b/yarn.lock index cd86ce1..80a5588 100644 --- a/yarn.lock +++ b/yarn.lock @@ -701,6 +701,11 @@ "@parcel/watcher-win32-ia32" "2.5.6" "@parcel/watcher-win32-x64" "2.5.6" +"@polka/url@^1.0.0-next.24": + version "1.0.0-next.29" + resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1" + integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww== + "@radix-ui/number@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090" @@ -3096,6 +3101,11 @@ motion-utils@^12.36.0: resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.36.0.tgz#cff2df2a28c3fe53a3de7e0103ba7f73ff7d77a7" integrity sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg== +mrmime@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc" + integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ== + ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" @@ -3665,6 +3675,15 @@ siginfo@^2.0.0: resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== +sirv@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.2.tgz#f775fccf10e22a40832684848d636346f41cd970" + integrity sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g== + dependencies: + "@polka/url" "^1.0.0-next.24" + mrmime "^2.0.0" + totalist "^3.0.0" + "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" @@ -3826,6 +3845,11 @@ tldts@^7.0.5: dependencies: tldts-core "^7.0.30" +totalist@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8" + integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ== + tough-cookie@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-6.0.1.tgz#a495f833836609ed983c19bc65639cfbceb54c76"