merge: integrate duckietm/Dev (JSON mode selector, split-gamedata script, installer, IT→ENG)

# Conflicts:
#	vite.config.mjs
This commit is contained in:
simoleo89
2026-05-19 17:04:58 +02:00
11 changed files with 1310 additions and 6 deletions
+4
View File
@@ -0,0 +1,4 @@
install.sh text eol=lf
install.mjs text eol=lf
*.sh text eol=lf
*.bat text eol=crlf
+3
View File
@@ -29,6 +29,9 @@ Thumbs.db
.env .env
.claude/ .claude/
# Per-deploy build configuration (yarn configure)
/.nitro-build.json
# Local runtime config copies # Local runtime config copies
/public/configuration/renderer-config.json /public/configuration/renderer-config.json
/public/configuration/ui-config.json /public/configuration/ui-config.json
+222 -1
View File
@@ -7,7 +7,163 @@
- If using NodeJS < 18 remove `--openssl-legacy-provider` from the package.json scripts - If using NodeJS < 18 remove `--openssl-legacy-provider` from the package.json scripts
- [Yarn](https://yarnpkg.com/) `npm i yarn -g` - [Yarn](https://yarnpkg.com/) `npm i yarn -g`
## Installation ## Quick install (recommended)
The repository ships a cross-platform installer that performs the full setup
in one go: prerequisites check, renderer clone & link, dependency install,
config copy, JSON parsing mode selection, URL prompt with validation, and the
production build.
After cloning Nitro V3, from its root run:
```
# Windows
install.bat
# Linux / macOS
./install.sh
```
Both wrappers just exec `node install.mjs`, so you can also invoke it directly:
```
node install.mjs
```
The installer walks through these steps:
```
[1/9] Check prerequisites (node >= 18, yarn, git)
[2/9] Clone Nitro_Render_V3
[3/9] Setup renderer (yarn install + yarn link)
[4/9] Setup client (yarn install + yarn link "@nitrots/nitro-renderer")
[5/9] Copy public/configuration/*.example -> *.json
[6/9] Choose JSON parsing mode (json5 recommended) -> writes .nitro-build.json
[7/9] Configure URLs (interactive, validated)
[8/9] Build (yarn build)
[9/9] Summary
```
### Headless / CI runs
Every step can be driven from flags so the installer can be used in pipelines:
```
node install.mjs --non-interactive \
--json-mode=json5 \
--socket-url=wss://example.com/ws \
--api-url=https://example.com \
--asset-url=https://example.com/nitro-assets/ \
--image-library-url=https://example.com/c_images \
--hof-furni-url=https://example.com/hof_furni \
--camera-url=https://example.com/camera \
--thumbnails-url=https://example.com/thumbnails \
--habbopages-url=/habbopages \
--api-base-url=https://example.com \
--plain-config-base-url=https://example.com/configuration \
--plain-gamedata-base-url=https://example.com/gamedata \
--skip-link
```
Useful workflow flags:
- `--non-interactive` / `--skip-prompts` — keep example defaults unless a URL override is passed
- `--json-mode=<json5\|legacy\|auto>` — pick the parser without the JSON mode prompt
- `--skip-build`, `--skip-clone`, `--skip-link` — re-runs without redoing those steps
- `--help` — full flag reference and per-key URL flags
`install.mjs` is idempotent: re-running it keeps any `*.json` config files
that already exist and only patches the URL keys you pass on the CLI.
## Splitting gamedata
The renderer can load gamedata files (FigureData, FurnitureData, FigureMap,
EffectMap, ProductData, HabboAvatarActions, ExternalTexts, UITexts) either as
a single legacy JSON/JSON5 file or as a **directory of small files** organised
in three tiers: `core/` (vendor baseline), `custom/` (your additions / overrides),
`seasonal/` (date-bound content such as Christmas or Easter).
The split layout is much easier to maintain — you edit a small focused file
instead of a 43 MB FurnitureData.json — and lets you keep vendor and operator
content cleanly separated.
### Directory layout
```
nitro-assets/gamedata/furnidata/
manifest.json5 # { "tiers": ["core", "custom", "seasonal"] }
core/
manifest.json5 # { "files": ["floor-001.json5", ..., "wall-001.json5"] }
floor-001.json5
floor-002.json5
wall-001.json5
custom/ # OPTIONAL — created by you
manifest.json5 # { "files": ["my-rares.json5"] }
my-rares.json5
seasonal/ # OPTIONAL — created by you
manifest.json5
xmas-2026.json5
```
Each tier is loaded in order. Within a tier, files load in the order listed in
its `manifest.json5`. Items in later layers override items in earlier layers
when they share the same identifier (`id`, `classname`, `name`, or the
top-level key for flat dictionaries).
### Generating the `core/` tier from a legacy file
Use the bundled CLI splitter:
```
node scripts/split-gamedata.mjs \
--input ~/legacy-gamedata/FurnitureData.json \
--output ~/nitro-assets/gamedata/furnidata
```
It auto-detects the gamedata type from the file's top-level keys and applies
the strategy that makes the most sense:
| Type | Split strategy |
|----------------------|---------------------------------------------|
| EffectMap | one file per effect `type` (dance, fx, ...) |
| FigureData | one `palettes.json5` + one file per setType |
| FigureMap | chunks of `libraries` (default 500/file) |
| FurnitureData | floor / wall, chunks of `furnitype` (300) |
| HabboAvatarActions | grouped by `state` (or single file if ≤1) |
| ProductData | chunks of products (default 500) |
| ExternalTexts/UITexts| grouped by key prefix (e.g. `gamecenter.*`) |
Useful flags: `--type=<name>` to force the type, `--chunk-size=N` to override
the default chunk size, `--json` to emit standard JSON instead of JSON5,
`--force` to overwrite an existing output directory. Full reference:
```
node scripts/split-gamedata.mjs --help
```
We only ship the `core/` tier with vendor baselines — `custom/` and `seasonal/`
are operator-owned: create their manifests when you need them and the loader
picks them up automatically.
### Pointing the renderer at a directory
In `public/configuration/renderer-config.json`, replace the legacy file URL
with the directory URL (note the trailing slash — that's how the loader
detects split mode):
```json5
{
// single file (legacy, still supported):
"furnidata.url": "https://example.com/nitro-assets/gamedata/FurnitureData.json",
// directory (split mode):
"furnidata.url": "https://example.com/nitro-assets/gamedata/furnidata/",
}
```
Both styles work; you can migrate one gamedata file at a time.
## Installation (manual)
- First you should open terminal and navigate to the folder where you want to clone Nitro and Nitro-Renderer - First you should open terminal and navigate to the folder where you want to clone Nitro and Nitro-Renderer
- Clone Nitro (Expl. C:\Github\) - Clone Nitro (Expl. C:\Github\)
@@ -31,6 +187,71 @@
- `yarn build` <== the final step to build the DIST folder this is where your browser needs to point / or upload this to your /client if you do the compile on a other machine (preferd) - `yarn build` <== the final step to build the DIST folder this is where your browser needs to point / or upload this to your /client if you do the compile on a other machine (preferd)
- You can override any variable by passing it to `NitroConfig` in the index.html - You can override any variable by passing it to `NitroConfig` in the index.html
## JSON / JSON5 configuration mode
Starting with this version of Nitro V3, you can choose how the client parses the
configuration files (`renderer-config.json`, `ui-config.json`, `client-mode.json`,
and the gamedata JSONs served by the renderer):
- **JSON5** (recommended) — accepts comments, trailing commas, single quotes
and unquoted identifiers. Easier to maintain, especially in `ui-config.json`
where you may want inline notes.
- **JSON (legacy strict)** — only valid standard JSON is accepted. Any comment
or trailing comma will fail the load with a clear error.
### Picking a mode
The first time you run `yarn start` or `yarn build`, an interactive prompt asks
which mode to use:
```
════════════════════════════════════════════════════════════
Nitro V3 — JSON mode configuration
════════════════════════════════════════════════════════════
1) JSON5 (recommended)
2) JSON (legacy strict)
Scelta [1=JSON5]:
```
Your choice is stored in `.nitro-build.json` at the project root (gitignored, so
each deployment keeps its own setting). Subsequent builds reuse it silently.
### Changing the mode later
Run the prompt again at any time:
```
yarn configure
```
You can also set the mode without interaction (useful in CI / scripts):
```
# one-shot override for a single build
NITRO_JSON_MODE=legacy yarn build
NITRO_JSON_MODE=json5 yarn build
# write the choice persistently
echo '{"jsonMode":"legacy"}' > .nitro-build.json
```
The recognized values are `legacy`, `json5`, and `auto` (auto = try strict JSON
first, fall back to JSON5 — equivalent to the original Render V3 behaviour).
### How it propagates
The chosen mode is injected at build time as the compile-time constant
`__NITRO_JSON_MODE__`. It is honoured by:
- `src/bootstrap.ts` when loading `client-mode.json`
- `@nitrots/utils``JsonParser.ts` in Render V3, used for every config file
and every gamedata JSON loaded by the renderer
In `legacy` mode, an invalid file produces a clear error that suggests switching
to JSON5; nothing is silently coerced.
## Usage ## Usage
- To use Nitro you need `.nitro` assets generated, see [nitro-converter](https://git.krews.org/nitro/nitro-converter) for instructions - To use Nitro you need `.nitro` assets generated, see [nitro-converter](https://git.krews.org/nitro/nitro-converter) for instructions
+2
View File
@@ -0,0 +1,2 @@
@echo off
node "%~dp0install.mjs" %*
+556
View File
@@ -0,0 +1,556 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { copyFile, readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { platform } from 'node:os';
import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
const ROOT = dirname(fileURLToPath(import.meta.url));
const RENDERER_REPO_URL = 'https://github.com/duckietm/Nitro_Render_V3.git';
const RENDERER_DIR = resolve(ROOT, '..', 'Nitro_Render_V3');
const CONFIG_DIR = join(ROOT, 'public', 'configuration');
const NITRO_BUILD_FILE = join(ROOT, '.nitro-build.json');
const IS_WINDOWS = platform() === 'win32';
const MIN_NODE_MAJOR = 18;
const VALID_JSON_MODES = ['json5', 'legacy', 'auto'];
const DEFAULT_JSON_MODE = 'json5';
const KEY_SPECS = {
'socket.url': { type: 'url', schemes: ['ws:', 'wss:'], flag: 'socket-url' },
'api.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'api-url' },
'asset.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'asset-url' },
'image.library.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'image-library-url' },
'hof.furni.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'hof-furni-url' },
'camera.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'camera-url' },
'thumbnails.url': { type: 'url', schemes: ['http:', 'https:'], flag: 'thumbnails-url' },
'url.prefix': { type: 'pathOrUrl', schemes: ['http:', 'https:'], flag: 'url-prefix' },
'habbopages.url': { type: 'pathOrUrl', schemes: ['http:', 'https:'], flag: 'habbopages-url' },
'apiBaseUrl': { type: 'url', schemes: ['http:', 'https:'], flag: 'api-base-url' },
'plainConfigBaseUrl': { type: 'url', schemes: ['http:', 'https:'], flag: 'plain-config-base-url' },
'plainGamedataBaseUrl': { type: 'url', schemes: ['http:', 'https:'], flag: 'plain-gamedata-base-url' }
};
const FLAG_TO_KEY = Object.fromEntries(
Object.entries(KEY_SPECS).map(([key, spec]) => [spec.flag, key])
);
const CONFIG_FILES = [
{
example: 'renderer-config.example',
target: 'renderer-config.json',
keys: ['socket.url', 'api.url', 'asset.url', 'image.library.url', 'hof.furni.url']
},
{
example: 'ui-config.example',
target: 'ui-config.json',
keys: ['camera.url', 'thumbnails.url', 'url.prefix', 'habbopages.url']
},
{
example: 'client-mode.example',
target: 'client-mode.json',
keys: ['apiBaseUrl', 'plainConfigBaseUrl', 'plainGamedataBaseUrl']
}
];
const STEPS = [
'Check prerequisites',
'Clone Nitro_Render_V3',
'Setup renderer (yarn install + yarn link)',
'Setup client (yarn install + yarn link)',
'Copy config files',
'Choose JSON parsing mode',
'Configure URLs',
'Build (yarn build)',
'Summary'
];
let currentStep = 0;
let activeReadline = null;
const summary = {
rendererCloned: false,
rendererSkipped: false,
configsCreated: [],
configsKept: [],
configsPatched: [],
jsonMode: null,
jsonModeSource: null,
buildRan: false,
buildSkipped: false
};
const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
const c = {
reset: useColor ? '\x1b[0m' : '',
bold: useColor ? '\x1b[1m' : '',
dim: useColor ? '\x1b[2m' : '',
red: useColor ? '\x1b[31m' : '',
green: useColor ? '\x1b[32m' : '',
yellow: useColor ? '\x1b[33m' : '',
cyan: useColor ? '\x1b[36m' : ''
};
function info(msg) { console.log(c.cyan + '[i]' + c.reset + ' ' + msg); }
function ok(msg) { console.log(c.green + '[+]' + c.reset + ' ' + msg); }
function warn(msg) { console.log(c.yellow + '[!]' + c.reset + ' ' + msg); }
function err(msg) { console.error(c.red + '[x]' + c.reset + ' ' + msg); }
function step(label) {
currentStep += 1;
const sep = '----------------------------------------------------------------';
console.log('');
console.log(c.dim + sep + c.reset);
console.log(c.bold + '[' + currentStep + '/' + STEPS.length + '] ' + label + c.reset);
console.log(c.dim + sep + c.reset);
}
function runShell(cmdString, cwd) {
return new Promise((resolveFn, rejectFn) => {
const child = spawn(cmdString, { shell: true, cwd, stdio: 'inherit' });
child.on('error', e => rejectFn(new Error("'" + cmdString + "' (cwd: " + cwd + ") failed to start: " + e.message)));
child.on('exit', code => {
if (code === 0) resolveFn();
else rejectFn(new Error("'" + cmdString + "' (cwd: " + cwd + ") exited with code " + code));
});
});
}
function runCapture(cmdString) {
return new Promise((resolveFn, rejectFn) => {
const child = spawn(cmdString, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
child.on('error', e => rejectFn(new Error("'" + cmdString + "' failed to start: " + e.message)));
child.on('exit', code => {
if (code === 0) resolveFn(stdout.trim());
else rejectFn(new Error("'" + cmdString + "' exited with code " + code + (stderr ? ': ' + stderr.trim() : '')));
});
});
}
function validateValue(value, spec) {
if (value === '' || value === undefined || value === null) {
if (spec.type === 'pathOrUrl') return { valid: true };
return { valid: false, error: 'value cannot be empty' };
}
if (spec.type === 'pathOrUrl' && value.startsWith('/')) {
return { valid: true };
}
let parsed;
try {
parsed = new URL(value);
} catch {
return { valid: false, error: 'not a valid URL' };
}
if (spec.schemes.length > 0 && !spec.schemes.includes(parsed.protocol)) {
const allowed = spec.schemes.map(s => s.replace(':', '')).join(', ');
return { valid: false, error: 'scheme must be one of: ' + allowed };
}
return { valid: true };
}
function parseArgs() {
const opts = {
interactive: true,
skipBuild: false,
skipClone: false,
skipLink: false,
help: false,
jsonMode: null,
urlOverrides: {}
};
const builtinFlags = new Set([
'--non-interactive', '--skip-prompts',
'--skip-build', '--skip-clone', '--skip-link',
'--help', '-h'
]);
for (const arg of process.argv.slice(2)) {
if (builtinFlags.has(arg)) {
switch (arg) {
case '--non-interactive':
case '--skip-prompts': opts.interactive = false; break;
case '--skip-build': opts.skipBuild = true; break;
case '--skip-clone': opts.skipClone = true; break;
case '--skip-link': opts.skipLink = true; break;
case '--help':
case '-h': opts.help = true; break;
}
continue;
}
const eq = arg.indexOf('=');
if (arg.startsWith('--') && eq > 2) {
const flagName = arg.slice(2, eq);
const value = arg.slice(eq + 1);
if (flagName === 'json-mode') {
if (!VALID_JSON_MODES.includes(value)) {
warn('Invalid --json-mode=' + value + ' (expected: ' + VALID_JSON_MODES.join(', ') + '), ignored');
continue;
}
opts.jsonMode = value;
continue;
}
const key = FLAG_TO_KEY[flagName];
if (key) {
opts.urlOverrides[key] = value;
continue;
}
}
warn('Unknown flag: ' + arg + ' (ignored)');
}
return opts;
}
function printUsage() {
const flagList = Object.entries(KEY_SPECS)
.map(([key, spec]) => ' --' + spec.flag + '=<value>' + ' '.repeat(Math.max(1, 32 - spec.flag.length)) + 'Set ' + key)
.join('\n');
console.log([
'Nitro-V3 cross-platform installer',
'',
'Usage: node install.mjs [flags]',
'',
'Workflow flags:',
' --non-interactive, --skip-prompts Keep default URLs unless overridden by --<key>=<value>',
' --json-mode=<json5|legacy|auto> Choose the JSON parsing mode without prompting',
' --skip-build Skip the final yarn build',
' --skip-clone Skip cloning Nitro_Render_V3',
' --skip-link Skip yarn link calls (useful when re-running)',
' --help, -h Show this help and exit',
'',
'URL override flags (override interactive prompts; combine with --non-interactive for fully automated runs):',
flagList,
'',
'Steps performed:',
' 1. Check Node >= ' + MIN_NODE_MAJOR + ', yarn, git',
' 2. Clone Nitro_Render_V3 to ../Nitro_Render_V3',
' 3. yarn install + yarn link in the renderer',
' 4. yarn install + yarn link "@nitrots/nitro-renderer" in this project',
' 5. Copy public/configuration/*.example -> *.json (keeps existing files)',
' 6. Choose JSON parsing mode (json5 recommended) -> writes .nitro-build.json',
' 7. Prompt for URLs and patch the JSON config files',
' 8. yarn build (honours the JSON mode chosen at step 6)',
''
].join('\n'));
}
async function checkPrereqs() {
const nodeVer = process.versions.node;
const major = parseInt(nodeVer.split('.')[0], 10);
if (Number.isNaN(major) || major < MIN_NODE_MAJOR) {
throw new Error('Node >= ' + MIN_NODE_MAJOR + ' required (you have v' + nodeVer + '). Install from https://nodejs.org/');
}
ok('Node v' + nodeVer);
try {
const v = await runCapture('yarn --version');
ok('yarn ' + v);
} catch {
const hint = IS_WINDOWS ? 'npm i -g yarn' : 'sudo npm i -g yarn';
throw new Error('yarn not found on PATH. Install with: ' + hint);
}
try {
const v = await runCapture('git --version');
ok(v);
} catch {
const hint = IS_WINDOWS ? 'winget install Git.Git' : 'sudo apt-get install git (or your distro equivalent)';
throw new Error('git not found on PATH. Install with: ' + hint);
}
}
async function cloneRenderer(opts) {
if (opts.skipClone) { info('--skip-clone: not cloning Nitro_Render_V3'); summary.rendererSkipped = true; return; }
if (existsSync(RENDERER_DIR)) {
warn('Nitro_Render_V3 already exists at ' + RENDERER_DIR + ' - skipping clone (yarn install/link will still run).');
summary.rendererSkipped = true;
return;
}
await runShell('git clone ' + RENDERER_REPO_URL + ' "' + RENDERER_DIR + '"', dirname(RENDERER_DIR));
summary.rendererCloned = true;
ok('Cloned Nitro_Render_V3 to ' + RENDERER_DIR);
}
async function setupRenderer(opts) {
if (!existsSync(RENDERER_DIR)) {
throw new Error('Renderer directory not found: ' + RENDERER_DIR + '. Re-run without --skip-clone or clone it manually.');
}
await runShell('yarn install', RENDERER_DIR);
if (opts.skipLink) { info('--skip-link: skipping yarn link in renderer'); return; }
try {
await runShell('yarn link', RENDERER_DIR);
} catch (e) {
warn('yarn link in renderer failed (likely already linked): ' + e.message);
}
}
async function setupClient(opts) {
await runShell('yarn install', ROOT);
if (opts.skipLink) { info('--skip-link: skipping yarn link in client'); return; }
try {
await runShell('yarn link "@nitrots/nitro-renderer"', ROOT);
} catch (e) {
warn('yarn link "@nitrots/nitro-renderer" failed (likely already linked): ' + e.message);
}
}
async function writeJsonMode(mode) {
const payload = { jsonMode: mode, configuredAt: new Date().toISOString() };
await writeFile(NITRO_BUILD_FILE, JSON.stringify(payload, null, 2) + '\n', 'utf8');
}
async function chooseJsonMode(opts) {
if (opts.jsonMode) {
await writeJsonMode(opts.jsonMode);
summary.jsonMode = opts.jsonMode;
summary.jsonModeSource = 'CLI (--json-mode)';
ok('JSON mode set to ' + opts.jsonMode + ' (from --json-mode)');
return;
}
let existing = null;
if (existsSync(NITRO_BUILD_FILE)) {
try {
const raw = await readFile(NITRO_BUILD_FILE, 'utf8');
const parsed = JSON.parse(raw);
if (VALID_JSON_MODES.includes(parsed?.jsonMode)) existing = parsed.jsonMode;
} catch {}
}
if (!opts.interactive) {
const mode = existing || DEFAULT_JSON_MODE;
await writeJsonMode(mode);
summary.jsonMode = mode;
summary.jsonModeSource = existing ? 'existing .nitro-build.json' : 'default (non-interactive)';
info('--non-interactive: JSON mode = ' + mode + (existing ? ' (preserved)' : ' (default)'));
return;
}
info('Pick how configuration files (renderer-config, ui-config, gamedata) are parsed.');
info(' 1) JSON5 (recommended - accepts comments, trailing commas, single quotes)');
info(' 2) JSON (legacy strict - only standard JSON valid)');
if (existing) info(' Current value in .nitro-build.json: ' + existing);
const rl = readline.createInterface({ input, output });
activeReadline = rl;
let chosen = null;
try {
while (chosen === null) {
const defaultLabel = existing || '1=JSON5';
const answer = (await rl.question(' Choice [' + defaultLabel + ']: ')).trim().toLowerCase();
if (answer.length === 0) {
chosen = existing || DEFAULT_JSON_MODE;
} else if (answer === '1' || answer === 'json5' || answer === 'y' || answer === 'yes') {
chosen = 'json5';
} else if (answer === '2' || answer === 'json' || answer === 'legacy' || answer === 'n' || answer === 'no') {
chosen = 'legacy';
} else if (answer === 'auto') {
chosen = 'auto';
} else {
warn('Invalid choice. Enter 1, 2, json5, json, legacy, or auto.');
}
}
} finally {
activeReadline = null;
rl.close();
}
await writeJsonMode(chosen);
summary.jsonMode = chosen;
summary.jsonModeSource = 'interactive prompt';
ok('JSON mode set to ' + chosen + ' -> wrote .nitro-build.json');
if (chosen === 'legacy') {
warn('Legacy mode is strict: config files must be valid standard JSON (no comments, no trailing commas).');
}
}
async function copyConfigs() {
for (const entry of CONFIG_FILES) {
const src = join(CONFIG_DIR, entry.example);
const dst = join(CONFIG_DIR, entry.target);
if (!existsSync(src)) {
throw new Error('Missing example file: ' + src);
}
if (existsSync(dst)) {
warn(entry.target + ' already exists - keeping existing file (URL overrides will still patch it).');
summary.configsKept.push(entry.target);
} else {
await copyFile(src, dst);
ok('Created ' + entry.target);
summary.configsCreated.push(entry.target);
}
}
}
async function applyOverridesNonInteractive(opts) {
for (const entry of CONFIG_FILES) {
const dst = join(CONFIG_DIR, entry.target);
const raw = await readFile(dst, 'utf8');
let obj;
try {
obj = JSON.parse(raw);
} catch (e) {
throw new Error('Could not parse ' + entry.target + ' as JSON: ' + e.message);
}
let changed = false;
for (const key of entry.keys) {
if (Object.prototype.hasOwnProperty.call(opts.urlOverrides, key)) {
const value = opts.urlOverrides[key];
const result = validateValue(value, KEY_SPECS[key]);
if (!result.valid) {
throw new Error('Invalid value for --' + KEY_SPECS[key].flag + '=' + JSON.stringify(value) + ': ' + result.error);
}
if (obj[key] !== value) {
obj[key] = value;
changed = true;
}
}
}
if (changed) {
await writeFile(dst, JSON.stringify(obj, null, 4) + '\n');
ok('Updated ' + entry.target + ' (from CLI flags)');
summary.configsPatched.push(entry.target);
}
}
}
async function promptConfigs(opts) {
const overrideKeys = Object.keys(opts.urlOverrides);
if (!opts.interactive) {
if (overrideKeys.length > 0) {
info('--non-interactive with ' + overrideKeys.length + ' URL override(s); applying without prompts');
await applyOverridesNonInteractive(opts);
} else {
info('--non-interactive: keeping URL values from .example defaults');
}
return;
}
info('Press Enter to keep the current value shown in [brackets]. URLs are validated.');
if (overrideKeys.length > 0) {
info('CLI overrides take precedence and skip prompts: ' + overrideKeys.map(k => '--' + KEY_SPECS[k].flag).join(', '));
}
const rl = readline.createInterface({ input, output });
activeReadline = rl;
try {
for (const entry of CONFIG_FILES) {
const dst = join(CONFIG_DIR, entry.target);
const raw = await readFile(dst, 'utf8');
let obj;
try {
obj = JSON.parse(raw);
} catch (e) {
throw new Error('Could not parse ' + entry.target + ' as JSON: ' + e.message);
}
console.log('\n ' + c.bold + entry.target + c.reset);
let changed = false;
for (const key of entry.keys) {
const spec = KEY_SPECS[key];
if (Object.prototype.hasOwnProperty.call(opts.urlOverrides, key)) {
const value = opts.urlOverrides[key];
const result = validateValue(value, spec);
if (!result.valid) {
throw new Error('Invalid value for --' + spec.flag + '=' + JSON.stringify(value) + ': ' + result.error);
}
if (obj[key] !== value) { obj[key] = value; changed = true; }
console.log(' ' + c.dim + key + ' = ' + value + ' (from --' + spec.flag + ')' + c.reset);
continue;
}
const current = obj[key] === undefined ? '' : String(obj[key]);
while (true) {
const answer = await rl.question(' ' + key + ' [' + current + ']: ');
const trimmed = answer.trim();
if (trimmed.length === 0) break;
if (trimmed === current) break;
const result = validateValue(trimmed, spec);
if (!result.valid) {
warn('Invalid: ' + result.error + '. Try again or press Enter to keep current.');
continue;
}
obj[key] = trimmed;
changed = true;
break;
}
}
if (changed) {
await writeFile(dst, JSON.stringify(obj, null, 4) + '\n');
ok('Updated ' + entry.target);
summary.configsPatched.push(entry.target);
} else {
info('No changes to ' + entry.target);
}
}
} finally {
activeReadline = null;
rl.close();
}
}
async function runBuild(opts) {
if (opts.skipBuild) { info('--skip-build: skipping yarn build'); summary.buildSkipped = true; return; }
await runShell('yarn build', ROOT);
summary.buildRan = true;
}
function printSummary() {
const distPath = join(ROOT, 'dist');
console.log('');
console.log(c.bold + '================================================================' + c.reset);
console.log(c.bold + ' Installation summary' + c.reset);
console.log(c.bold + '================================================================' + c.reset);
console.log(' Renderer: ' + RENDERER_DIR + (summary.rendererCloned ? ' (cloned)' : ' (already present)'));
if (summary.configsCreated.length) console.log(' Created: ' + summary.configsCreated.join(', '));
if (summary.configsKept.length) console.log(' Kept: ' + summary.configsKept.join(', '));
if (summary.configsPatched.length) console.log(' Patched: ' + summary.configsPatched.join(', '));
if (summary.jsonMode) console.log(' JSON mode: ' + summary.jsonMode + (summary.jsonModeSource ? ' (' + summary.jsonModeSource + ')' : ''));
if (summary.buildRan) console.log(' Build: ' + c.green + 'OK' + c.reset + ' -> ' + distPath);
else if (summary.buildSkipped) console.log(' Build: skipped');
console.log('');
console.log(' Next steps:');
console.log(' - Development: yarn start');
if (summary.buildRan) {
console.log(' - Production: deploy the contents of ' + distPath + ' to your webserver');
} else {
console.log(' - Production: yarn build, then deploy ' + distPath);
}
console.log(c.bold + '================================================================' + c.reset);
}
async function main() {
const opts = parseArgs();
if (opts.help) { printUsage(); process.exit(0); }
console.log(c.bold + 'Nitro-V3 installer' + c.reset + ' (' + (IS_WINDOWS ? 'Windows' : platform()) + ')');
console.log('Project root: ' + ROOT);
step(STEPS[0]); await checkPrereqs();
step(STEPS[1]); await cloneRenderer(opts);
step(STEPS[2]); await setupRenderer(opts);
step(STEPS[3]); await setupClient(opts);
step(STEPS[4]); await copyConfigs();
step(STEPS[5]); await chooseJsonMode(opts);
step(STEPS[6]); await promptConfigs(opts);
step(STEPS[7]); await runBuild(opts);
step(STEPS[8]); printSummary();
}
process.on('SIGINT', () => {
if (activeReadline) {
try { activeReadline.close(); } catch {}
activeReadline = null;
}
const label = STEPS[currentStep - 1] || 'startup';
console.error('');
warn('Aborted at step ' + currentStep + ' (' + label + ')');
process.exit(130);
});
main().catch(e => {
const label = STEPS[currentStep - 1] || 'startup';
err('');
err('Step ' + currentStep + ' (' + label + ') failed:');
err(' ' + e.message);
process.exit(1);
});
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -e
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec node "$DIR/install.mjs" "$@"
+3 -1
View File
@@ -4,7 +4,9 @@
"homepage": ".", "homepage": ".",
"private": true, "private": true,
"scripts": { "scripts": {
"prebuild": "node scripts/write-asset-loader.mjs", "configure": "node scripts/configure-json.mjs",
"prestart": "node scripts/configure-json.mjs --if-missing",
"prebuild": "node scripts/configure-json.mjs --if-missing && node scripts/write-asset-loader.mjs",
"start": "vite --host", "start": "vite --host",
"build": "vite build && node scripts/minify-dist.mjs", "build": "vite build && node scripts/minify-dist.mjs",
"build:prod": "npx browserslist@latest --update-db && yarn build", "build:prod": "npx browserslist@latest --update-db && yarn build",
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
import { fileURLToPath } from 'url';
import readline from 'readline';
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
const PROJECT_ROOT = resolve(SCRIPT_DIR, '..');
const CONFIG_FILE = resolve(PROJECT_ROOT, '.nitro-build.json');
const VALID_MODES = new Set(['legacy', 'json5']);
const DEFAULT_MODE = 'json5';
const args = process.argv.slice(2);
const ifMissing = args.includes('--if-missing');
const nonInteractive = args.includes('--non-interactive') || !process.stdin.isTTY;
const readExisting = () =>
{
if(!existsSync(CONFIG_FILE)) return null;
try
{
const raw = readFileSync(CONFIG_FILE, 'utf8');
const parsed = JSON.parse(raw);
if(parsed && VALID_MODES.has(parsed.jsonMode)) return parsed;
}
catch {}
return null;
};
const writeChoice = (mode) =>
{
const payload = {
jsonMode: mode,
configuredAt: new Date().toISOString()
};
writeFileSync(CONFIG_FILE, `${ JSON.stringify(payload, null, 2) }\n`, 'utf8');
};
const printBanner = () =>
{
const line = '═'.repeat(60);
process.stdout.write(`\n${ line }\n Nitro V3 — JSON mode configuration\n${ line }\n\n`);
process.stdout.write('Configuration files (renderer-config, ui-config, gamedata)\ncan be parsed in two ways:\n\n');
process.stdout.write(' 1) JSON5 (recommended — accepts comments, trailing commas,\n single quotes, unquoted identifiers)\n');
process.stdout.write(' 2) JSON (legacy strict — only standard valid JSON)\n\n');
};
const normalizeAnswer = (raw) =>
{
const v = (raw || '').trim().toLowerCase();
if(!v || v === '1' || v === 'json5' || v === 'y' || v === 'yes') return 'json5';
if(v === '2' || v === 'json' || v === 'legacy' || v === 'n' || v === 'no') return 'legacy';
return null;
};
const promptUser = () => new Promise(resolveFn =>
{
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = () =>
{
rl.question('Choice [1=JSON5]: ', answer =>
{
const normalized = normalizeAnswer(answer);
if(normalized === null)
{
process.stdout.write(' ↳ Invalid response. Please enter 1, 2, json5 or json.\n');
return ask();
}
rl.close();
resolveFn(normalized);
});
};
ask();
});
const main = async () =>
{
const existing = readExisting();
if(ifMissing && existing)
{
process.stdout.write(`[configure-json] mode already configured: ${ existing.jsonMode } (skip)\n`);
return;
}
if(nonInteractive)
{
const mode = existing?.jsonMode || DEFAULT_MODE;
writeChoice(mode);
process.stdout.write(`[configure-json] non-interactive — saved: ${ mode }\n`);
return;
}
printBanner();
if(existing) process.stdout.write(`Current mode: ${ existing.jsonMode }\n\n`);
const choice = await promptUser();
writeChoice(choice);
process.stdout.write(`\n✓ Saved to .nitro-build.json — mode: ${ choice }\n`);
if(choice === 'legacy')
{
process.stdout.write(' Warning: config files must be strict valid JSON\n (no comments, no trailing commas).\n');
}
else
{
process.stdout.write(' JSON5 active: you can use comments, trailing commas and single quotes\n in configuration files.\n');
}
process.stdout.write('\n To change mode in the future: yarn configure\n\n');
};
main().catch(err =>
{
process.stderr.write(`[configure-json] error: ${ err?.message || err }\n`);
process.exit(1);
});
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { dirname, basename, resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HELP = `
Nitro V3 — gamedata splitter
Takes a legacy single-file gamedata JSON (EffectMap, FigureData, FigureMap,
FurnitureData, HabboAvatarActions, ProductData, ExternalTexts, UITexts) and
produces the directory layout consumed by the split-aware loader:
<output>/
manifest.json5 tier order (defaults to core/custom/seasonal)
core/
manifest.json5 files list, in load order
<part1>.json5
<part2>.json5
...
Custom and seasonal tiers are NOT generated — those are operator-owned and
will be auto-discovered by the loader if their manifest.json5 exists.
Usage:
node scripts/split-gamedata.mjs --input <file> --output <dir> [flags]
Required:
--input <path> Path to the legacy JSON (or JSON5) file
--output <dir> Target directory (will contain core/, manifest.json5)
Optional:
--type <name> Force the gamedata type (effectmap, figuredata,
figuremap, furnidata, avatar-actions, productdata,
external-texts, ui-texts). Default: auto-detect
--chunk-size <N> Items per chunk for the bucket-based splits.
Defaults: figuremap=500, furnidata=300, productdata=500,
external-texts/ui-texts split by prefix instead
--json (or --legacy) Emit standard JSON instead of JSON5 (no comments)
--force Overwrite the output directory if it already exists
--help, -h Show this help
Examples:
node scripts/split-gamedata.mjs \\
--input ~/gamedata/FurnitureData.json \\
--output ~/nitro-assets/gamedata/furnidata
node scripts/split-gamedata.mjs \\
--input ./EffectMap.json --output ./effectmap --chunk-size 50
After splitting, point your renderer-config at the directory (note the
trailing slash):
"furnidata.url": "https://example.com/nitro-assets/gamedata/furnidata/"
`;
const args = process.argv.slice(2);
const opts = {
input: null,
output: null,
type: null,
chunkSize: null,
asJson5: true,
force: false,
help: false
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--help' || a === '-h') opts.help = true;
else if (a === '--input') opts.input = args[++i];
else if (a === '--output') opts.output = args[++i];
else if (a === '--type') opts.type = args[++i];
else if (a === '--chunk-size') opts.chunkSize = parseInt(args[++i], 10);
else if (a === '--json' || a === '--legacy') opts.asJson5 = false;
else if (a === '--json5') opts.asJson5 = true;
else if (a === '--force') opts.force = true;
else if (a.startsWith('--input=')) opts.input = a.slice('--input='.length);
else if (a.startsWith('--output=')) opts.output = a.slice('--output='.length);
else if (a.startsWith('--type=')) opts.type = a.slice('--type='.length);
else if (a.startsWith('--chunk-size=')) opts.chunkSize = parseInt(a.slice('--chunk-size='.length), 10);
else {
process.stderr.write(`Unknown flag: ${ a }\n`);
process.exit(2);
}
}
if (opts.help) {
process.stdout.write(HELP);
process.exit(0);
}
if (!opts.input || !opts.output) {
process.stderr.write('Missing --input or --output. Use --help for usage.\n');
process.exit(2);
}
if (!existsSync(opts.input)) {
process.stderr.write(`Input file not found: ${ opts.input }\n`);
process.exit(1);
}
const detectType = (data) => {
if (!data || typeof data !== 'object') return null;
const keys = new Set(Object.keys(data));
if (keys.has('roomitemtypes') || keys.has('wallitemtypes')) return 'furnidata';
if (keys.has('palettes') && keys.has('setTypes')) return 'figuredata';
if (keys.has('libraries')) return 'figuremap';
if (keys.has('effects')) return 'effectmap';
if (keys.has('actions')) return 'avatar-actions';
if (keys.has('productdata')) return 'productdata';
// Flat dict heuristic: many top-level scalar keys with dots → texts
if (keys.size > 30 && Object.values(data).every(v => typeof v === 'string')) {
const sampleKey = Object.keys(data)[0] || '';
return sampleKey.includes('.') ? 'external-texts' : 'ui-texts';
}
return null;
};
const splitArrayInChunks = (arr, size) => {
const chunks = [];
for (let i = 0; i < arr.length; i += size) chunks.push(arr.slice(i, i + size));
return chunks;
};
const splitByPrefix = (dict, maxPerBucket = 800) => {
const buckets = new Map();
for (const key of Object.keys(dict)) {
const prefix = key.split('.')[0] || '_other';
if (!buckets.has(prefix)) buckets.set(prefix, {});
buckets.get(prefix)[key] = dict[key];
}
// Merge small buckets into a shared one to reduce manifest noise
const out = [];
const small = {};
let smallCount = 0;
for (const [name, content] of buckets) {
const size = Object.keys(content).length;
if (size < 50) {
Object.assign(small, content);
smallCount += size;
} else {
// split further if oversized
if (size > maxPerBucket) {
const entries = Object.entries(content);
const slices = splitArrayInChunks(entries, maxPerBucket);
slices.forEach((slice, idx) => {
const part = {};
for (const [k, v] of slice) part[k] = v;
out.push([ `${ name }-${ String(idx + 1).padStart(3, '0') }`, part ]);
});
} else {
out.push([ name, content ]);
}
}
}
if (smallCount > 0) out.push([ '_misc', small ]);
return out;
};
const sanitizeName = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
const ext = () => opts.asJson5 ? 'json5' : 'json';
const writePart = (filePath, data, headerComment) => {
const body = JSON.stringify(data, null, 4);
const out = opts.asJson5 && headerComment
? `// ${ headerComment }\n${ body }\n`
: `${ body }\n`;
writeFileSync(filePath, out, 'utf8');
};
const buildSplits = (type, data) => {
switch (type) {
case 'effectmap': {
const effects = data.effects || [];
const byType = new Map();
for (const e of effects) {
const t = sanitizeName(e.type || 'other');
if (!byType.has(t)) byType.set(t, []);
byType.get(t).push(e);
}
return Array.from(byType, ([ t, items ]) => ({
name: `effects-${ t }.${ ext() }`,
comment: `Effects of type "${ t }" (${ items.length } items)`,
content: { effects: items }
}));
}
case 'figuredata': {
const parts = [];
if (data.palettes?.length) {
parts.push({
name: `palettes.${ ext() }`,
comment: `Color palettes (${ data.palettes.length })`,
content: { palettes: data.palettes }
});
}
for (const st of (data.setTypes || [])) {
const t = sanitizeName(st.type);
parts.push({
name: `settype-${ t }.${ ext() }`,
comment: `setType "${ st.type }" (paletteId=${ st.paletteId })`,
content: { setTypes: [ st ] }
});
}
return parts;
}
case 'figuremap': {
const libs = data.libraries || [];
const size = opts.chunkSize || 500;
const chunks = splitArrayInChunks(libs, size);
return chunks.map((chunk, idx) => ({
name: `libraries-${ String(idx + 1).padStart(3, '0') }.${ ext() }`,
comment: `libraries ${ idx * size + 1 }..${ idx * size + chunk.length } of ${ libs.length }`,
content: { libraries: chunk }
}));
}
case 'furnidata': {
const parts = [];
const size = opts.chunkSize || 300;
const floor = data.roomitemtypes?.furnitype || [];
const wall = data.wallitemtypes?.furnitype || [];
const floorChunks = splitArrayInChunks(floor, size);
floorChunks.forEach((chunk, idx) => {
parts.push({
name: `floor-${ String(idx + 1).padStart(3, '0') }.${ ext() }`,
comment: `Floor furniture ${ idx * size + 1 }..${ idx * size + chunk.length } of ${ floor.length }`,
content: { roomitemtypes: { furnitype: chunk } }
});
});
const wallChunks = splitArrayInChunks(wall, size);
wallChunks.forEach((chunk, idx) => {
parts.push({
name: `wall-${ String(idx + 1).padStart(3, '0') }.${ ext() }`,
comment: `Wall furniture ${ idx * size + 1 }..${ idx * size + chunk.length } of ${ wall.length }`,
content: { wallitemtypes: { furnitype: chunk } }
});
});
return parts;
}
case 'avatar-actions': {
const actions = data.actions || [];
const byState = new Map();
for (const a of actions) {
const s = sanitizeName(a.state || 'other');
if (!byState.has(s)) byState.set(s, []);
byState.get(s).push(a);
}
if (byState.size <= 1) {
return [ {
name: `actions.${ ext() }`,
comment: `All avatar actions (${ actions.length })`,
content: { actions }
} ];
}
return Array.from(byState, ([ s, items ]) => ({
name: `actions-${ s }.${ ext() }`,
comment: `Actions in state "${ s }" (${ items.length })`,
content: { actions: items }
}));
}
case 'productdata': {
const products = data.productdata?.product || [];
const size = opts.chunkSize || 500;
const chunks = splitArrayInChunks(products, size);
return chunks.map((chunk, idx) => ({
name: `products-${ String(idx + 1).padStart(3, '0') }.${ ext() }`,
comment: `Products ${ idx * size + 1 }..${ idx * size + chunk.length } of ${ products.length }`,
content: { productdata: { product: chunk } }
}));
}
case 'external-texts':
case 'ui-texts': {
const buckets = splitByPrefix(data, opts.chunkSize || 800);
return buckets.map(([ name, content ]) => ({
name: `${ sanitizeName(name) }.${ ext() }`,
comment: `${ name } (${ Object.keys(content).length } keys)`,
content
}));
}
default:
throw new Error(`Unknown gamedata type: ${ type }. Use --type to force one.`);
}
};
const main = () => {
const raw = readFileSync(opts.input, 'utf8');
let data;
try {
data = JSON.parse(raw);
} catch {
try {
const JSON5 = require('json5');
data = JSON5.parse(raw);
} catch (e) {
process.stderr.write(`Could not parse ${ opts.input } as JSON nor JSON5: ${ e.message }\n`);
process.exit(1);
}
}
const type = opts.type || detectType(data);
if (!type) {
process.stderr.write('Could not auto-detect gamedata type. Pass --type=<name>. See --help.\n');
process.exit(1);
}
const outDir = resolve(opts.output);
const coreDir = join(outDir, 'core');
if (existsSync(outDir)) {
if (!opts.force) {
process.stderr.write(`Output directory already exists: ${ outDir }. Use --force to overwrite.\n`);
process.exit(1);
}
rmSync(outDir, { recursive: true, force: true });
}
mkdirSync(coreDir, { recursive: true });
const parts = buildSplits(type, data);
if (!parts.length) {
process.stderr.write(`No content produced for type ${ type }. Input may be empty.\n`);
process.exit(1);
}
for (const part of parts) {
writePart(join(coreDir, part.name), part.content, part.comment);
}
const coreManifest = {
files: parts.map(p => p.name)
};
const coreManifestBody = opts.asJson5
? `// Auto-generated by split-gamedata.mjs from ${ basename(opts.input) }\n// Type: ${ type }${ parts.length } files, ${ parts.reduce((n, p) => n + JSON.stringify(p.content).length, 0).toLocaleString() } chars total\n${ JSON.stringify(coreManifest, null, 4) }\n`
: `${ JSON.stringify(coreManifest, null, 4) }\n`;
writeFileSync(join(coreDir, `manifest.${ ext() }`), coreManifestBody, 'utf8');
const rootManifest = { tiers: [ 'core', 'custom', 'seasonal' ] };
const rootManifestBody = opts.asJson5
? `// Root manifest — load order of tiers (later overrides earlier by id/classname).\n// Drop a custom/manifest.${ ext() } and/or seasonal/manifest.${ ext() } to add\n// override tiers without touching core/.\n${ JSON.stringify(rootManifest, null, 4) }\n`
: `${ JSON.stringify(rootManifest, null, 4) }\n`;
writeFileSync(join(outDir, `manifest.${ ext() }`), rootManifestBody, 'utf8');
process.stdout.write([
`[split-gamedata] ${ type } -> ${ outDir }`,
` ${ parts.length } file(s) in core/`,
` tiers: core (always loaded), custom (optional), seasonal (optional)`,
` point renderer-config at: ${ outDir.replace(/\\/g, '/') }/`,
''
].join('\n'));
};
try {
main();
} catch (e) {
process.stderr.write(`[split-gamedata] ${ e.message }\n`);
process.exit(1);
}
+31 -3
View File
@@ -2,6 +2,22 @@ import { GetConfiguration } from '@nitrots/nitro-renderer';
import JSON5 from 'json5'; import JSON5 from 'json5';
import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets'; import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets';
declare const __NITRO_JSON_MODE__: 'legacy' | 'json5' | 'auto' | undefined;
const resolveJsonMode = (): 'legacy' | 'json5' | 'auto' =>
{
try
{
if(typeof __NITRO_JSON_MODE__ !== 'undefined' && __NITRO_JSON_MODE__)
{
if(__NITRO_JSON_MODE__ === 'legacy' || __NITRO_JSON_MODE__ === 'json5' || __NITRO_JSON_MODE__ === 'auto') return __NITRO_JSON_MODE__;
}
}
catch {}
return 'auto';
};
const ensureMobileViewport = () => const ensureMobileViewport = () =>
{ {
let viewport = document.querySelector<HTMLMetaElement>('meta[name="viewport"]'); let viewport = document.querySelector<HTMLMetaElement>('meta[name="viewport"]');
@@ -78,16 +94,28 @@ const loadClientMode = async () =>
if(!response.ok) throw new Error(`HTTP ${ response.status }`); if(!response.ok) throw new Error(`HTTP ${ response.status }`);
const text = await response.text(); const text = await response.text();
const mode = resolveJsonMode();
try if(mode === 'legacy')
{ {
(window as any).__nitroClientMode = JSON.parse(text); (window as any).__nitroClientMode = JSON.parse(text);
} }
catch else if(mode === 'json5')
{ {
(window as any).__nitroClientMode = JSON5.parse(text); (window as any).__nitroClientMode = JSON5.parse(text);
} }
setBootDebug('boot: client-mode loaded'); else
{
try
{
(window as any).__nitroClientMode = JSON.parse(text);
}
catch
{
(window as any).__nitroClientMode = JSON5.parse(text);
}
}
setBootDebug(`boot: client-mode loaded (mode=${ mode })`);
} }
catch(error) catch(error)
{ {
+26 -1
View File
@@ -1,5 +1,5 @@
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { existsSync } from 'fs'; import { existsSync, readFileSync } from 'fs';
import { resolve } from 'path'; import { resolve } from 'path';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import sirv from 'sirv'; import sirv from 'sirv';
@@ -73,6 +73,28 @@ const ReactCompilerConfig = {
target: '19' target: '19'
}; };
const resolveJsonMode = () =>
{
const envOverride = process.env.NITRO_JSON_MODE;
if(envOverride === 'legacy' || envOverride === 'json5' || envOverride === 'auto') return envOverride;
const configFile = resolve(__dirname, '.nitro-build.json');
if(existsSync(configFile))
{
try
{
const parsed = JSON.parse(readFileSync(configFile, 'utf8'));
if(parsed?.jsonMode === 'legacy' || parsed?.jsonMode === 'json5' || parsed?.jsonMode === 'auto') return parsed.jsonMode;
}
catch {}
}
return 'auto';
};
const nitroJsonMode = resolveJsonMode();
process.stdout.write(`[vite] __NITRO_JSON_MODE__ = ${ nitroJsonMode }\n`);
export default defineConfig({ export default defineConfig({
base: process.env.VITE_BASE || './', base: process.env.VITE_BASE || './',
plugins: [ plugins: [
@@ -85,6 +107,9 @@ export default defineConfig({
}), }),
nitroAssetsServer() nitroAssetsServer()
], ],
define: {
__NITRO_JSON_MODE__: JSON.stringify(nitroJsonMode)
},
server: { server: {
fs: { fs: {
allow: [ allow: [