🆕 Added support for JSON5

This commit is contained in:
duckietm
2026-05-18 16:14:38 +02:00
parent b6a26fbd84
commit 31df26bd1b
17 changed files with 494 additions and 281 deletions
+5 -5
View File
@@ -1,5 +1,5 @@
import { IAssetData, IAssetManager, IGraphicAsset, IGraphicAssetCollection } from '@nitrots/api';
import { NitroBundle, NitroLogger } from '@nitrots/utils';
import { NitroBundle, NitroLogger, parseConfigJsonFromResponse } from '@nitrots/utils';
import { AnimatedGIF } from '@pixi/gif';
import { Assets, Spritesheet, SpritesheetData, Texture } from 'pixi.js';
import { GraphicAssetCollection } from './GraphicAssetCollection';
@@ -159,7 +159,7 @@ export class AssetManager implements IAssetManager
}
}
}
else if(url.endsWith('.json'))
else if(url.endsWith('.json') || url.endsWith('.json5'))
{
let response: Response;
@@ -178,18 +178,18 @@ export class AssetManager implements IAssetManager
try
{
data = await response.json() as IAssetData;
data = await parseConfigJsonFromResponse<IAssetData>(response, url);
}
catch(parseErr)
{
throw new Error(`Invalid JSON in "${ url }" — the URL may be wrong and returning an HTML page instead of JSON (${ parseErr.message })`);
throw new Error(`Invalid asset data "${ url }" — JSON/JSON5 parse failed (${ parseErr.message })`);
}
let texture: Texture = null;
const imagePath = data?.spritesheet?.meta?.image;
const fallbackImagePath = ((data?.name && data.name.length > 0)
? `${data.name}.png`
: url.replace(/\.json$/i, '.png'));
: url.replace(/\.json5?$/i, '.png'));
const resolvedImageUrl = (imagePath
? new URL(imagePath, url).toString()
: new URL(fallbackImagePath, url).toString());
@@ -1,6 +1,7 @@
import { IAssetManager, IAvatarFigureContainer, IAvatarImageListener } from '@nitrots/api';
import { GetConfiguration } from '@nitrots/configuration';
import { AvatarRenderLibraryEvent, GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
import { parseConfigJsonFromResponse } from '@nitrots/utils';
import { AvatarAssetDownloadLibrary } from './AvatarAssetDownloadLibrary';
import { AvatarStructure } from './AvatarStructure';
@@ -57,11 +58,11 @@ export class AvatarAssetDownloadManager
try
{
responseData = await response.json();
responseData = await parseConfigJsonFromResponse(response, url);
}
catch(parseErr)
{
throw new Error(`Invalid JSON in figure map "${ url }" — the URL may be wrong. Check "avatar.figuremap.url" in renderer-config.json (${ parseErr.message })`);
throw new Error(`Invalid figure map "${ url }" — JSON/JSON5 parse failed. Check "avatar.figuremap.url" in renderer-config.json (${ parseErr.message })`);
}
this.processFigureMap(responseData.libraries);
+5 -4
View File
@@ -2,6 +2,7 @@ import { AvatarSetType, IAssetManager, IAvatarEffectListener, IAvatarFigureConta
import { GetAssetManager } from '@nitrots/assets';
import { GetConfiguration } from '@nitrots/configuration';
import { GetEventDispatcher, NitroEventType } from '@nitrots/events';
import { parseConfigJsonFromResponse } from '@nitrots/utils';
import { AvatarAssetDownloadManager } from './AvatarAssetDownloadManager';
import { AvatarFigureContainer } from './AvatarFigureContainer';
import { AvatarImage } from './AvatarImage';
@@ -87,11 +88,11 @@ export class AvatarRenderManager implements IAvatarRenderManager
try
{
this._structure.updateActions(await response.json());
this._structure.updateActions(await parseConfigJsonFromResponse(response, url));
}
catch(parseErr)
{
throw new Error(`Invalid JSON from "${ url }" — the URL may be wrong and returning an HTML page instead of JSON. Check "avatar.actions.url" in renderer-config.json (${ parseErr.message })`);
throw new Error(`Invalid avatar actions "${ url }" — JSON/JSON5 parse failed. Check "avatar.actions.url" in renderer-config.json (${ parseErr.message })`);
}
}
@@ -120,11 +121,11 @@ export class AvatarRenderManager implements IAvatarRenderManager
try
{
this._structure.figureData.appendJSON(await response.json());
this._structure.figureData.appendJSON(await parseConfigJsonFromResponse(response, url));
}
catch(parseErr)
{
throw new Error(`Invalid JSON from "${ url }" — the URL may be wrong and returning an HTML page instead of JSON. Check "avatar.figuredata.url" in renderer-config.json (${ parseErr.message })`);
throw new Error(`Invalid figure data "${ url }" — JSON/JSON5 parse failed. Check "avatar.figuredata.url" in renderer-config.json (${ parseErr.message })`);
}
this._structure.init();
@@ -1,6 +1,7 @@
import { IAssetManager, IAvatarEffectListener } from '@nitrots/api';
import { GetConfiguration } from '@nitrots/configuration';
import { AvatarRenderEffectLibraryEvent, GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
import { parseConfigJsonFromResponse } from '@nitrots/utils';
import { AvatarStructure } from './AvatarStructure';
import { EffectAssetDownloadLibrary } from './EffectAssetDownloadLibrary';
@@ -48,11 +49,11 @@ export class EffectAssetDownloadManager
try
{
responseData = await response.json();
responseData = await parseConfigJsonFromResponse(response, url);
}
catch(parseErr)
{
throw new Error(`Invalid JSON in effect map "${ url }" — the URL may be wrong. Check "avatar.effectmap.url" in renderer-config.json (${ parseErr.message })`);
throw new Error(`Invalid effect map "${ url }" — JSON/JSON5 parse failed. Check "avatar.effectmap.url" in renderer-config.json (${ parseErr.message })`);
}
this.processEffectMap(responseData.effects);
@@ -1,4 +1,4 @@
import { NitroLogger, NitroVersion } from '@nitrots/utils';
import { NitroLogger, NitroVersion, parseConfigJsonFromResponse } from '@nitrots/utils';
import { IConfigurationManager } from './IConfigurationManager';
export class ConfigurationManager implements IConfigurationManager
@@ -54,11 +54,11 @@ export class ConfigurationManager implements IConfigurationManager
try
{
json = await response.json();
json = await parseConfigJsonFromResponse(response, url);
}
catch(parseError)
{
throw new Error(`Invalid JSON in config "${ url }" — check for syntax errors like trailing commas or missing quotes (${ parseError.message })`);
throw new Error(`Invalid config "${ url }" — JSON/JSON5 parse failed. JSON5 allows comments, trailing commas and unquoted keys (${ parseError.message })`);
}
this.parseConfiguration(json);
+1
View File
@@ -13,6 +13,7 @@
"@nitrots/communication": "1.0.0",
"@nitrots/configuration": "1.0.0",
"@nitrots/events": "1.0.0",
"@nitrots/utils": "1.0.0",
"pixi.js": "^8.8.1"
},
"devDependencies": {
@@ -1,6 +1,7 @@
import { ILocalizationManager } from '@nitrots/api';
import { BadgePointLimitsEvent, GetCommunication } from '@nitrots/communication';
import { GetConfiguration } from '@nitrots/configuration';
import { parseConfigJsonFromResponse } from '@nitrots/utils';
import { BadgeBaseAndLevel } from './BadgeBaseAndLevel';
export class LocalizationManager implements ILocalizationManager
@@ -42,11 +43,11 @@ export class LocalizationManager implements ILocalizationManager
try
{
data = await response.json();
data = await parseConfigJsonFromResponse(response, url);
}
catch(parseErr)
{
throw new Error(`Invalid JSON in localization file "${ url }" — the URL may be wrong. Check "external.texts.url" in ui-config.json (${ parseErr.message })`);
throw new Error(`Invalid localization file "${ url }" — JSON/JSON5 parse failed. Check "external.texts.url" in ui-config.json (${ parseErr.message })`);
}
this.parseLocalization(data);
+1
View File
@@ -15,6 +15,7 @@
"@nitrots/configuration": "1.0.0",
"@nitrots/events": "1.0.0",
"@nitrots/localization": "1.0.0",
"@nitrots/utils": "1.0.0",
"pixi.js": "^8.8.1"
},
"devDependencies": {
+2 -2
View File
@@ -3,7 +3,7 @@ import { AccountSafetyLockStatusChangeMessageEvent, AccountSafetyLockStatusChang
import { GetConfiguration } from '@nitrots/configuration';
import { GetLocalizationManager } from '@nitrots/localization';
import { GetEventDispatcher, MysteryBoxKeysUpdateEvent, NitroSettingsEvent, SessionDataPreferencesEvent, UserNameUpdateEvent } from '@nitrots/events';
import { CreateLinkEvent, HabboWebTools } from '@nitrots/utils';
import { CreateLinkEvent, HabboWebTools, parseConfigJsonFromResponse } from '@nitrots/utils';
import { Texture } from 'pixi.js';
import { GroupInformationManager } from './GroupInformationManager';
import { IgnoredUsersManager } from './IgnoredUsersManager';
@@ -152,7 +152,7 @@ export class SessionDataManager implements ISessionDataManager
if(response.status !== 200) throw new Error(`Unable to load ${ url }`);
const data = await response.json();
const data = await parseConfigJsonFromResponse(response, url);
this._floorItemOverrides = this.parseFurnitureOverrides(data?.roomitemtypes?.furnitype || []);
this._wallItemOverrides = this.parseFurnitureOverrides(data?.wallitemtypes?.furnitype || []);
@@ -1,6 +1,7 @@
import { FurnitureType, IFurnitureData } from '@nitrots/api';
import { GetConfiguration } from '@nitrots/configuration';
import { GetLocalizationManager } from '@nitrots/localization';
import { parseConfigJsonFromResponse } from '@nitrots/utils';
import { FurnitureData } from './FurnitureData';
export class FurnitureDataLoader
@@ -37,11 +38,11 @@ export class FurnitureDataLoader
try
{
responseData = await response.json();
responseData = await parseConfigJsonFromResponse(response, url);
}
catch(parseErr)
{
throw new Error(`Invalid JSON in furniture data "${ url }" — the URL may be wrong. Check "furnidata.url" in renderer-config.json (${ parseErr.message })`);
throw new Error(`Invalid furniture data "${ url }" — JSON/JSON5 parse failed. Check "furnidata.url" in renderer-config.json (${ parseErr.message })`);
}
if(responseData.roomitemtypes) this.parseFloorItems(responseData.roomitemtypes);
@@ -1,5 +1,6 @@
import { IProductData } from '@nitrots/api';
import { GetConfiguration } from '@nitrots/configuration';
import { parseConfigJsonFromResponse } from '@nitrots/utils';
import { ProductData } from './ProductData';
export class ProductDataLoader
@@ -34,11 +35,11 @@ export class ProductDataLoader
try
{
responseData = await response.json();
responseData = await parseConfigJsonFromResponse(response, url);
}
catch(parseErr)
{
throw new Error(`Invalid JSON in product data "${ url }" — the URL may be wrong. Check "productdata.url" in renderer-config.json (${ parseErr.message })`);
throw new Error(`Invalid product data "${ url }" — JSON/JSON5 parse failed. Check "productdata.url" in renderer-config.json (${ parseErr.message })`);
}
this.parseProducts(responseData.productdata);
+1
View File
@@ -10,6 +10,7 @@
"main": "./index",
"dependencies": {
"@nitrots/api": "1.0.0",
"json5": "^2.2.3",
"pako": "^2.1.0",
"pixi.js": "^8.8.1"
},
+88
View File
@@ -0,0 +1,88 @@
import JSON5 from 'json5';
const JSON5_EXTENSION = /\.json5(?:[?#]|$)/i;
const JSON5_MIME = /(?:application|text)\/(?:json5|x-json5)/i;
const looksLikeJson5Url = (url: string): boolean => !!url && JSON5_EXTENSION.test(url);
const looksLikeJson5ContentType = (contentType: string): boolean => !!contentType && JSON5_MIME.test(contentType);
const formatParseError = (sourceUrl: string, strictError: unknown, json5Error: unknown): string =>
{
const strictMessage = (strictError as Error)?.message || String(strictError);
const json5Message = (json5Error as Error)?.message || String(json5Error);
const source = sourceUrl ? ` in "${ sourceUrl }"` : '';
if(strictMessage === json5Message) return `Failed to parse JSON/JSON5${ source }${ json5Message }`;
return `Failed to parse JSON/JSON5${ source } — JSON5: ${ json5Message } (strict JSON: ${ strictMessage })`;
};
export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''): T =>
{
if(text === null || text === undefined) throw new Error(`Empty response${ sourceUrl ? ` for "${ sourceUrl }"` : '' }`);
const trimmed = text.length > 0 ? text : '';
if(looksLikeJson5Url(sourceUrl))
{
try
{
return JSON5.parse<T>(trimmed);
}
catch(err)
{
throw new Error(formatParseError(sourceUrl, err, err));
}
}
let strictError: unknown;
try
{
return JSON.parse(trimmed) as T;
}
catch(err)
{
strictError = err;
}
try
{
return JSON5.parse<T>(trimmed);
}
catch(json5Error)
{
throw new Error(formatParseError(sourceUrl, strictError, json5Error));
}
};
export const parseConfigJsonFromResponse = async <T = any>(response: Response, sourceUrl: string = ''): Promise<T> =>
{
const contentType = response.headers?.get?.('content-type') || '';
const text = await response.text();
const url = sourceUrl || (response as any).url || '';
if(looksLikeJson5ContentType(contentType) && !looksLikeJson5Url(url))
{
try
{
return JSON5.parse<T>(text);
}
catch(err)
{
throw new Error(formatParseError(url, err, err));
}
}
return parseConfigJson<T>(text, url);
};
export const fetchConfigJson = async <T = any>(url: string, init?: RequestInit): Promise<T> =>
{
const response = await fetch(url, init);
if(!response || response.status !== 200) throw new Error(`Failed to fetch "${ url }" — server returned HTTP ${ response?.status ?? 'no response' }`);
return parseConfigJsonFromResponse<T>(response, url);
};
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import { fetchConfigJson, parseConfigJson, parseConfigJsonFromResponse } from '../JsonParser';
describe('parseConfigJson', () =>
{
it('parses strict JSON', () =>
{
const result = parseConfigJson('{"a": 1, "b": [2, 3]}');
expect(result).toEqual({ a: 1, b: [ 2, 3 ] });
});
it('falls back to JSON5 for trailing commas', () =>
{
const result = parseConfigJson('{"a": 1, "b": [2, 3,],}');
expect(result).toEqual({ a: 1, b: [ 2, 3 ] });
});
it('falls back to JSON5 for comments', () =>
{
const result = parseConfigJson(`{
// a number
"a": 1,
/* a list */
"b": [2, 3]
}`);
expect(result).toEqual({ a: 1, b: [ 2, 3 ] });
});
it('falls back to JSON5 for unquoted keys and single quotes', () =>
{
const result = parseConfigJson("{ a: 1, b: 'hello' }");
expect(result).toEqual({ a: 1, b: 'hello' });
});
it('uses JSON5 directly for .json5 URLs', () =>
{
const result = parseConfigJson('{ a: 1, /* hi */ b: 2 }', 'https://example.com/cfg.json5');
expect(result).toEqual({ a: 1, b: 2 });
});
it('throws a helpful error when both strict and JSON5 fail', () =>
{
expect(() => parseConfigJson('{ this is :: not json ::', 'cfg.json'))
.toThrowError(/Failed to parse JSON\/JSON5 in "cfg\.json"/);
});
});
describe('parseConfigJsonFromResponse', () =>
{
const buildResponse = (body: string, contentType = 'application/json', url = 'https://example.com/x.json'): Response =>
{
const headers = new Headers({ 'content-type': contentType });
return new Response(body, { status: 200, headers });
};
it('parses JSON response bodies', async () =>
{
const res = buildResponse('{"a": 1}');
await expect(parseConfigJsonFromResponse(res, 'https://example.com/x.json')).resolves.toEqual({ a: 1 });
});
it('parses JSON5 response bodies with comments', async () =>
{
const res = buildResponse('{ /* yo */ a: 1, b: 2, }');
await expect(parseConfigJsonFromResponse(res, 'https://example.com/x.json')).resolves.toEqual({ a: 1, b: 2 });
});
it('respects application/json5 content-type', async () =>
{
const res = buildResponse('{ a: 1 }', 'application/json5');
await expect(parseConfigJsonFromResponse(res, 'https://example.com/x.txt')).resolves.toEqual({ a: 1 });
});
});
describe('fetchConfigJson', () =>
{
it('fetches and parses JSON or JSON5', async () =>
{
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response('{ a: 1, b: 2, }', {
status: 200,
headers: { 'content-type': 'application/json' }
})) as any;
try
{
await expect(fetchConfigJson('https://example.com/cfg.json')).resolves.toEqual({ a: 1, b: 2 });
}
finally
{
globalThis.fetch = originalFetch;
}
});
it('throws for non-200 responses', async () =>
{
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response('', { status: 404 })) as any;
try
{
await expect(fetchConfigJson('https://example.com/missing.json')).rejects.toThrowError(/HTTP 404/);
}
finally
{
globalThis.fetch = originalFetch;
}
});
});
+1
View File
@@ -13,6 +13,7 @@ export * from './GetTickerFPS';
export * from './GetTickerTime';
export * from './HabboWebTools';
export * from './Int32';
export * from './JsonParser';
export * from './LegacyExternalInterface';
export * from './LinkTracker';
export * from './Matrix4x4';