mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge pull request #146 from medievalshell/Dev
feat(loading): redesigned loader with progress bar, task labels, configurable assets + perf(build): granular code-split + preconnect hint for cold-load speed + docs: PERFORMANCE.md — client + server recipe for the 4s cold load
This commit is contained in:
@@ -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
|
||||||
|
<link rel="preconnect" href="https://challenges.cloudflare.com" crossorigin />
|
||||||
|
```
|
||||||
|
|
||||||
|
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=<uuid>&token_exp=<unix>`. 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://<your-domain>
|
||||||
|
Key: nitro.auth.remember
|
||||||
|
Value: {"token":"<uuid>","expiresAt":1781912345,"username":"<u>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
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://<your-domain>/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
|
||||||
|
<system.webServer>
|
||||||
|
<httpCompression directory="%SystemDrive%\inetpub\temp\IIS Temporary Compressed Files">
|
||||||
|
<scheme name="gzip" dll="%Windir%\system32\inetsrv\gzip.dll"
|
||||||
|
staticCompressionLevel="6" dynamicCompressionLevel="6" />
|
||||||
|
<dynamicTypes>
|
||||||
|
<add mimeType="application/json" enabled="true" />
|
||||||
|
<add mimeType="application/javascript" enabled="true" />
|
||||||
|
<add mimeType="text/css" enabled="true" />
|
||||||
|
<add mimeType="text/javascript" enabled="true" />
|
||||||
|
<add mimeType="text/*" enabled="true" />
|
||||||
|
</dynamicTypes>
|
||||||
|
<staticTypes>
|
||||||
|
<add mimeType="application/json" enabled="true" />
|
||||||
|
<add mimeType="application/javascript" enabled="true" />
|
||||||
|
<add mimeType="text/css" enabled="true" />
|
||||||
|
<add mimeType="text/javascript" enabled="true" />
|
||||||
|
<add mimeType="image/svg+xml" enabled="true" />
|
||||||
|
<add mimeType="font/ttf" enabled="true" />
|
||||||
|
<add mimeType="font/otf" enabled="true" />
|
||||||
|
<add mimeType="application/font-woff" enabled="true" />
|
||||||
|
<add mimeType="application/vnd.ms-fontobject" enabled="true" />
|
||||||
|
</staticTypes>
|
||||||
|
</httpCompression>
|
||||||
|
</system.webServer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify with PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri 'https://<your-domain>/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 `<location>`):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<location path="nitro-assets">
|
||||||
|
<system.webServer>
|
||||||
|
<staticContent>
|
||||||
|
<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="30.00:00:00" />
|
||||||
|
<mimeMap fileExtension=".json5" mimeType="application/json" />
|
||||||
|
<mimeMap fileExtension=".nitro" mimeType="application/octet-stream" />
|
||||||
|
</staticContent>
|
||||||
|
</system.webServer>
|
||||||
|
</location>
|
||||||
|
```
|
||||||
|
|
||||||
|
`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 `<location>`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<system.webServer>
|
||||||
|
<rewrite>
|
||||||
|
<rules>
|
||||||
|
<rule name="gamedata-dir-to-manifest-json5" stopProcessing="true">
|
||||||
|
<match url="^(nitro-assets/gamedata/[^?]+)/$" />
|
||||||
|
<conditions>
|
||||||
|
<add input="{REQUEST_FILENAME}/manifest.json5" matchType="IsFile" />
|
||||||
|
</conditions>
|
||||||
|
<action type="Rewrite" url="{R:1}/manifest.json5" />
|
||||||
|
</rule>
|
||||||
|
<rule name="gamedata-dir-to-manifest-json" stopProcessing="true">
|
||||||
|
<match url="^(nitro-assets/gamedata/[^?]+)/$" />
|
||||||
|
<conditions>
|
||||||
|
<add input="{REQUEST_FILENAME}/manifest.json" matchType="IsFile" />
|
||||||
|
</conditions>
|
||||||
|
<action type="Rewrite" url="{R:1}/manifest.json" />
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</rewrite>
|
||||||
|
</system.webServer>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<system.webServer>
|
||||||
|
<rewrite>
|
||||||
|
<rules>
|
||||||
|
<rule name="reverse-proxy-to-node" stopProcessing="true">
|
||||||
|
<match url="(.*)" />
|
||||||
|
<conditions>
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||||
|
</conditions>
|
||||||
|
<action type="Rewrite" url="http://127.0.0.1:3003/{R:1}" />
|
||||||
|
<serverVariables>
|
||||||
|
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
|
||||||
|
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
|
||||||
|
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
|
||||||
|
</serverVariables>
|
||||||
|
</rule>
|
||||||
|
<rule name="ws-proxy" stopProcessing="true">
|
||||||
|
<match url="^ws/(.*)" />
|
||||||
|
<action type="Rewrite" url="http://127.0.0.1:30001/{R:1}" />
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</rewrite>
|
||||||
|
</system.webServer>
|
||||||
|
```
|
||||||
|
|
||||||
|
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://<your-domain>/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://<your-domain>/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.
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>Nitro</title>
|
<title>Nitro</title>
|
||||||
|
<!-- Connection hints for the two domains the loader hits first.
|
||||||
|
dns-prefetch + preconnect halve the TLS round-trips on cold load. -->
|
||||||
|
<link rel="preconnect" href="https://challenges.cloudflare.com" crossorigin />
|
||||||
<script async defer src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
<script async defer src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -57,7 +57,10 @@
|
|||||||
const renderShell = () => {
|
const renderShell = () => {
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if(!root || root.firstChild) return;
|
if(!root || root.firstChild) return;
|
||||||
root.innerHTML = '<div style="position:fixed;inset:0;background:linear-gradient(180deg,#6eadc8 0%,#78b7cf 45%,#8ec4d7 100%);overflow:hidden;z-index:1"><div style="position:absolute;left:0;top:0;width:220px;height:220px;background:linear-gradient(135deg,rgba(255,255,255,.18),rgba(255,255,255,0));clip-path:polygon(0 0,100% 0,0 100%)"></div><div style="position:absolute;right:0;bottom:0;width:32vw;max-width:420px;height:100%;background:linear-gradient(270deg,rgba(255,255,255,.16),rgba(255,255,255,0))"></div><div style="position:absolute;top:50%;right:8vw;transform:translateY(-50%);display:flex;flex-direction:column;gap:18px;width:260px"><div style="height:86px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div><div style="height:190px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div></div></div>';
|
// 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 decodeAsset = (bytes) => {
|
||||||
|
|||||||
@@ -48,6 +48,26 @@
|
|||||||
"timezone.settings": "Europe/Amsterdam",
|
"timezone.settings": "Europe/Amsterdam",
|
||||||
"youtube.publish.disabled": false,
|
"youtube.publish.disabled": false,
|
||||||
"user.badges.group.slot.enabled": true,
|
"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.screen.enabled": true,
|
||||||
"login.endpoint": "${api.url}/api/auth/login",
|
"login.endpoint": "${api.url}/api/auth/login",
|
||||||
"login.register.endpoint": "${api.url}/api/auth/register",
|
"login.register.endpoint": "${api.url}/api/auth/register",
|
||||||
|
|||||||
@@ -228,7 +228,10 @@ const ASSET_LOADER_JS = `(() => {
|
|||||||
const renderShell = () => {
|
const renderShell = () => {
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if(!root || root.firstChild) return;
|
if(!root || root.firstChild) return;
|
||||||
root.innerHTML = '<div style="position:fixed;inset:0;background:linear-gradient(180deg,#6eadc8 0%,#78b7cf 45%,#8ec4d7 100%);overflow:hidden;z-index:1"><div style="position:absolute;left:0;top:0;width:220px;height:220px;background:linear-gradient(135deg,rgba(255,255,255,.18),rgba(255,255,255,0));clip-path:polygon(0 0,100% 0,0 100%)"></div><div style="position:absolute;right:0;bottom:0;width:32vw;max-width:420px;height:100%;background:linear-gradient(270deg,rgba(255,255,255,.16),rgba(255,255,255,0))"></div><div style="position:absolute;top:50%;right:8vw;transform:translateY(-50%);display:flex;flex-direction:column;gap:18px;width:260px"><div style="height:86px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div><div style="height:190px;background:#a2bfd1;border:2px solid #3f6a85;border-radius:8px;box-shadow:inset 0 2px rgba(255,255,255,.35),0 4px 6px rgba(0,0,0,.25)"></div></div></div>';
|
// 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 decodeAsset = (bytes) => {
|
||||||
|
|||||||
+120
-27
@@ -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 { 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 { 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 { Base } from './common';
|
||||||
import { LoadingView } from './components/loading/LoadingView';
|
import { LoadingView } from './components/loading/LoadingView';
|
||||||
import { LoginView } from './components/login/LoginView';
|
import { LoginView } from './components/login/LoginView';
|
||||||
@@ -72,6 +72,29 @@ export const App: FC<{}> = props =>
|
|||||||
const [ showLogin, setShowLogin ] = useState(false);
|
const [ showLogin, setShowLogin ] = useState(false);
|
||||||
const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin());
|
const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin());
|
||||||
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
|
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<string>(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<Promise<void>>(null);
|
const warmupPromiseRef = useRef<Promise<void>>(null);
|
||||||
const rendererPromiseRef = useRef<Promise<any>>(null);
|
const rendererPromiseRef = useRef<Promise<any>>(null);
|
||||||
const gameInitPromiseRef = useRef<Promise<void> | null>(null);
|
const gameInitPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
@@ -104,8 +127,36 @@ export const App: FC<{}> = props =>
|
|||||||
catch {}
|
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(() =>
|
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<unknown>('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
|
// Using console.warn (not NitroLogger.log) on purpose: NitroLogger
|
||||||
// is gated on LOG_DEBUG, which only flips to true once startWarmup's
|
// is gated on LOG_DEBUG, which only flips to true once startWarmup's
|
||||||
// GetConfiguration().init() completes. Auth-failure paths fire before
|
// GetConfiguration().init() completes. Auth-failure paths fire before
|
||||||
@@ -119,20 +170,7 @@ export const App: FC<{}> = props =>
|
|||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setShowLogin(true);
|
setShowLogin(true);
|
||||||
setIsEnteringHotel(false);
|
setIsEnteringHotel(false);
|
||||||
}, [ clearStoredCredentials ]);
|
}, [ clearStoredCredentials, showSessionExpired ]);
|
||||||
|
|
||||||
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) =>
|
const applySsoTicket = useCallback((ssoTicket: string) =>
|
||||||
{
|
{
|
||||||
@@ -352,6 +390,7 @@ export const App: FC<{}> = props =>
|
|||||||
warmupPromiseRef.current = (async () =>
|
warmupPromiseRef.current = (async () =>
|
||||||
{
|
{
|
||||||
await GetConfiguration().init();
|
await GetConfiguration().init();
|
||||||
|
bumpProgress(25, taskLabel('loading.task.warmup', 'Caricamento contenuti...'));
|
||||||
|
|
||||||
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
|
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
|
||||||
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
|
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
|
||||||
@@ -388,18 +427,29 @@ export const App: FC<{}> = props =>
|
|||||||
loginImageUrls.forEach(preloadImage);
|
loginImageUrls.forEach(preloadImage);
|
||||||
gamedataUrls.forEach(url => preloadUrl(url));
|
gamedataUrls.forEach(url => preloadUrl(url));
|
||||||
|
|
||||||
await Promise.all(
|
// Wire each warmup task to a progress bump so the bar reflects
|
||||||
[
|
// real subsystem-init completion, not a fake timer. Range 25→70.
|
||||||
GetAssetManager().downloadAssets(assetUrls),
|
// Each task carries a friendly label so the user sees what is
|
||||||
GetLocalizationManager().init(),
|
// currently being prepared instead of raw file names.
|
||||||
GetAvatarRenderManager().init(),
|
const warmupTasks: { promise: Promise<any>; label: string }[] = [
|
||||||
GetSoundManager().init()
|
{ 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;
|
return warmupPromiseRef.current;
|
||||||
}, [ startRenderer ]);
|
}, [ startRenderer, bumpProgress, taskLabel ]);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
@@ -427,13 +477,23 @@ export const App: FC<{}> = props =>
|
|||||||
{
|
{
|
||||||
const prepare = async (width: number, height: number) =>
|
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', {
|
console.warn('[App] prepare() start', {
|
||||||
hasNitroConfig: !!window.NitroConfig,
|
hasNitroConfig: !!window.NitroConfig,
|
||||||
ssoTicketInConfig: !!window.NitroConfig?.['sso.ticket'],
|
ssoTicketInConfig: !!window.NitroConfig?.['sso.ticket'],
|
||||||
hasRememberLocal: !!GetRememberLogin(),
|
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...');
|
||||||
|
setLoadingProgress(0);
|
||||||
|
setLoadingTask(bootLabel);
|
||||||
|
bumpProgress(5, bootLabel);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
|
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
|
||||||
@@ -441,6 +501,32 @@ export const App: FC<{}> = props =>
|
|||||||
let ssoTicket = window.NitroConfig['sso.ticket'];
|
let ssoTicket = window.NitroConfig['sso.ticket'];
|
||||||
if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket);
|
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 === '')
|
if(!ssoTicket || ssoTicket === '')
|
||||||
{
|
{
|
||||||
// Configuration is loaded lazily — fetch it up-front so the login
|
// Configuration is loaded lazily — fetch it up-front so the login
|
||||||
@@ -506,17 +592,23 @@ export const App: FC<{}> = props =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderer = await startRenderer(width, height);
|
const renderer = await startRenderer(width, height);
|
||||||
|
bumpProgress(20, taskLabel('loading.task.renderer', 'Inizializzazione renderer'));
|
||||||
|
|
||||||
await startWarmup(width, height);
|
await startWarmup(width, height);
|
||||||
|
bumpProgress(70, taskLabel('loading.task.startsession', 'Avvio sessione'));
|
||||||
|
|
||||||
if(!gameInitPromiseRef.current)
|
if(!gameInitPromiseRef.current)
|
||||||
{
|
{
|
||||||
gameInitPromiseRef.current = (async () =>
|
gameInitPromiseRef.current = (async () =>
|
||||||
{
|
{
|
||||||
await GetSessionDataManager().init();
|
await GetSessionDataManager().init();
|
||||||
|
bumpProgress(78, taskLabel('loading.task.userdata', 'Caricamento dati utente'));
|
||||||
await GetRoomSessionManager().init();
|
await GetRoomSessionManager().init();
|
||||||
|
bumpProgress(85, taskLabel('loading.task.rooms', 'Caricamento stanze'));
|
||||||
await GetRoomEngine().init();
|
await GetRoomEngine().init();
|
||||||
|
bumpProgress(92, taskLabel('loading.task.engine', 'Caricamento engine grafico'));
|
||||||
await GetCommunication().init();
|
await GetCommunication().init();
|
||||||
|
bumpProgress(98, taskLabel('loading.task.connect', 'Connessione al server'));
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +637,7 @@ export const App: FC<{}> = props =>
|
|||||||
GetTicker().add(ticker => GetTexturePool().run());
|
GetTicker().add(ticker => GetTexturePool().run());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bumpProgress(100, taskLabel('loading.task.ready', 'Pronto!'));
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
setShowLogin(false);
|
setShowLogin(false);
|
||||||
setIsEnteringHotel(false);
|
setIsEnteringHotel(false);
|
||||||
@@ -581,12 +674,12 @@ export const App: FC<{}> = props =>
|
|||||||
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
|
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
|
||||||
if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current);
|
if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current);
|
||||||
};
|
};
|
||||||
}, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin ]);
|
}, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin, bumpProgress, taskLabel ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Base fit overflow="hidden" className={ `nitro-app-root ${ !(window.devicePixelRatio % 1) ? 'image-rendering-pixelated' : '' }` }>
|
<Base fit overflow="hidden" className={ `nitro-app-root ${ !(window.devicePixelRatio % 1) ? 'image-rendering-pixelated' : '' }` }>
|
||||||
{ !isReady && !showLogin &&
|
{ !isReady && !showLogin &&
|
||||||
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
|
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } progress={ loadingProgress } currentTask={ loadingTask } /> }
|
||||||
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } isEntering={ isEnteringHotel } /> }
|
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } isEntering={ isEnteringHotel } /> }
|
||||||
{ isReady && <MainView /> }
|
{ isReady && <MainView /> }
|
||||||
{ /* Reconnect overlay must NOT render before we've actually entered
|
{ /* Reconnect overlay must NOT render before we've actually entered
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, loadGamedata, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||||
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa';
|
import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa';
|
||||||
import { LocalizeText, SendMessageComposer } from '../../api';
|
import { LocalizeText, SendMessageComposer } from '../../api';
|
||||||
@@ -65,9 +65,11 @@ export const AvatarEffectsView: FC<{}> = () =>
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const response = await fetch(url);
|
// The effectmap is served either as a single JSON file or as a
|
||||||
if(!response.ok) throw new Error(`HTTP ${ response.status }`);
|
// tiered directory with core/custom/seasonal manifests using
|
||||||
const json = await response.json();
|
// JSON5 syntax (// comments allowed). loadGamedata picks the
|
||||||
|
// right mode for us and merges tiers.
|
||||||
|
const json = await loadGamedata<{ effects?: EffectMapEntry[] }>(url);
|
||||||
if(cancelled) return;
|
if(cancelled) return;
|
||||||
|
|
||||||
const list: EffectMapEntry[] = Array.isArray(json?.effects)
|
const list: EffectMapEntry[] = Array.isArray(json?.effects)
|
||||||
|
|||||||
@@ -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 loadingGif from '@/assets/images/loading/loading.gif';
|
||||||
|
import nitroV3Logo from '@/assets/images/notifications/nitro_v3.png';
|
||||||
import { Base, Column, Text } from '../../common';
|
import { Base, Column, Text } from '../../common';
|
||||||
|
|
||||||
interface LoadingViewProps {
|
interface LoadingViewProps {
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
homeUrl?: string;
|
homeUrl?: string;
|
||||||
|
progress?: number;
|
||||||
|
currentTask?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveConfigUrl = (key: string): string =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const raw = GetConfiguration().getValue<string>(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<string>(key, '');
|
||||||
|
if(!raw) return fallback;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const LoadingView: FC<LoadingViewProps> = props =>
|
export const LoadingView: FC<LoadingViewProps> = 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 (
|
return (
|
||||||
<Column fullHeight position="fixed" className="fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]">
|
<Column fullHeight position="fixed" className={ backgroundClassName } style={ backgroundStyle }>
|
||||||
|
<img
|
||||||
|
src={ nitroV3Logo }
|
||||||
|
alt="Nitro V3"
|
||||||
|
draggable={ false }
|
||||||
|
className="absolute top-5 left-0 z-2 w-37.5 h-auto select-none pointer-events-none"
|
||||||
|
/>
|
||||||
<Base fullHeight className="container h-100">
|
<Base fullHeight className="container h-100">
|
||||||
<Column fullHeight alignItems="center" justifyContent="center">
|
<Column fullHeight alignItems="center" justifyContent="center">
|
||||||
<Base className="absolute top-[20px] left-[20px] z-[2] w-[150px] h-[100px] bg-[url('@/assets/images/notifications/nitro_v3.png')] bg-no-repeat bg-left-top" />
|
|
||||||
{ isError && (message && message.length) ?
|
{ isError && (message && message.length) ?
|
||||||
<Column alignItems="center" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 max-w-[80%]" gap={ 2 }>
|
<Column alignItems="center" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 max-w-[80%]" gap={ 2 }>
|
||||||
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
|
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
|
||||||
@@ -32,15 +87,59 @@ export const LoadingView: FC<LoadingViewProps> = props =>
|
|||||||
}
|
}
|
||||||
</Column>
|
</Column>
|
||||||
:
|
:
|
||||||
<Column alignItems="center" justifyContent="center" gap={ 3 } className="z-[3]">
|
<>
|
||||||
<img src={ loadingGif } alt="" draggable={ false } className="block w-auto h-auto select-none pointer-events-none" />
|
<Column alignItems="center" justifyContent="center" className="z-[3] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||||
{ message && message.length ?
|
<img
|
||||||
<Text fontSizeCustom={ 20 } variant="white" className="text-center [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
|
src={ customLogoUrl || loadingGif }
|
||||||
{ message }
|
alt=""
|
||||||
</Text>
|
draggable={ false }
|
||||||
: null
|
className="block w-auto h-auto max-w-[80vw] max-h-[40vh] select-none pointer-events-none"
|
||||||
|
/>
|
||||||
|
{ message && message.length ?
|
||||||
|
<Text fontSizeCustom={ 22 } variant="white" className="text-center mt-4 [text-shadow:0px_4px_4px_rgba(0,0,0,0.4)] tracking-wide">
|
||||||
|
{ message }
|
||||||
|
</Text>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</Column>
|
||||||
|
{ clampedProgress !== null &&
|
||||||
|
<Column
|
||||||
|
alignItems="center"
|
||||||
|
gap={ 2 }
|
||||||
|
className="absolute bottom-[8vh] left-1/2 -translate-x-1/2 z-[4] w-[min(900px,90vw)]"
|
||||||
|
>
|
||||||
|
<Base
|
||||||
|
className="relative w-full h-8 rounded-full overflow-hidden border border-white/30 shadow-[0_8px_24px_rgba(0,0,0,0.45)]"
|
||||||
|
style={ { background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(4px)' } }
|
||||||
|
>
|
||||||
|
<Base
|
||||||
|
className="h-full rounded-full transition-[width] duration-300 ease-out"
|
||||||
|
style={ { width: `${ clampedProgress }%`, background: progressBarColor, boxShadow: '0 0 18px rgba(79,140,255,0.55)' } }
|
||||||
|
/>
|
||||||
|
<Base
|
||||||
|
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||||
|
style={ { fontFamily: '"Poppins","Segoe UI",system-ui,sans-serif', fontWeight: 700, fontSize: '16px', color: '#fff', letterSpacing: '0.08em', textShadow: '0 2px 4px rgba(0,0,0,0.6)' } }
|
||||||
|
>
|
||||||
|
{ clampedProgress }%
|
||||||
|
</Base>
|
||||||
|
</Base>
|
||||||
|
<Base
|
||||||
|
className="text-center"
|
||||||
|
style={ {
|
||||||
|
fontFamily: '"Poppins","Segoe UI",system-ui,sans-serif',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '15px',
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
|
||||||
|
minHeight: '22px'
|
||||||
|
} }
|
||||||
|
>
|
||||||
|
{ currentTask }
|
||||||
|
</Base>
|
||||||
|
</Column>
|
||||||
}
|
}
|
||||||
</Column>
|
</>
|
||||||
}
|
}
|
||||||
</Column>
|
</Column>
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const { fakeStore } = vi.hoisted(() =>
|
|||||||
selectCatalogOffer: vi.fn(),
|
selectCatalogOffer: vi.fn(),
|
||||||
getNodeById: vi.fn(),
|
getNodeById: vi.fn(),
|
||||||
getNodeByName: vi.fn(),
|
getNodeByName: vi.fn(),
|
||||||
|
getNodesByOfferId: vi.fn(),
|
||||||
getBuilderFurniPlaceableStatus: vi.fn()
|
getBuilderFurniPlaceableStatus: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+34
-6
@@ -164,18 +164,46 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
assetFileNames: 'src/assets/[name]-[hash].[ext]',
|
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 =>
|
manualChunks: id =>
|
||||||
{
|
{
|
||||||
// Renderer source is consumed via filesystem alias
|
// Vendor checks first — pixi.js/howler are aliased to
|
||||||
// (../Nitro_Render_V3/packages/*/src) so it is NOT
|
// ../Nitro_Render_V3/node_modules so they match
|
||||||
// under node_modules — needs its own branch before
|
// `Nitro_Render_V3` too. Without this priority, they end
|
||||||
// the node_modules check.
|
// up bundled into nitro-renderer instead of getting their
|
||||||
if(id.includes('Nitro_Render_V3') || id.includes(`${ rendererRoot }`)) return 'nitro-renderer';
|
// 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('node_modules'))
|
||||||
{
|
{
|
||||||
if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3')) return 'nitro-renderer';
|
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';
|
return 'vendor';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user