mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
35f16a6745
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).
587 lines
22 KiB
Markdown
587 lines
22 KiB
Markdown
# 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.
|