You've already forked Nitro_Render_V3
mirror of
https://github.com/duckietm/Nitro_Render_V3.git
synced 2026-06-19 15:06:20 +00:00
feat(utils): parallelize gamedata loader + structured fetch errors
Three improvements on top of duckietm/Dev's new JSON5 + split-aware
gamedata loader:
1. Parallel fetches inside loadGamedata: every file declared in a
tier's manifest is now fetched with Promise.all. The merge step
still walks the parts in declared order so override semantics
(core -> custom -> seasonal, and within-tier declaration order)
are preserved. Root-manifest files and per-tier manifest discovery
also run concurrently.
2. tryFetchManifest distinguishes 404 from other failures. The
previous tryFetchOrNull silently treated parse errors and 5xx as
"manifest missing", so a malformed manifest.json5 made an entire
tier vanish from the boot. Now only HTTP 404 returns null; every
other failure propagates.
3. New ConfigJsonError class with phase ('fetch' | 'parse'),
sourceUrl, and optional httpStatus. Exported isMissingResource()
helper lets callers check for 404 without string-matching.
Also:
- mergeGamedata warns via NitroLogger when an array looks keyed by
id/classname/name on >=80% of items but a few are missing the
key (the previous behavior fell back to concat() and produced
silent duplicates).
- Removed the dead text === null/undefined branch in parseConfigJson
(Response.text() never returns null).
Verified: tsgo clean, 138/138 tests pass on the renderer, 207/207
tests pass on the client (no behavioral change to existing callers).
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { fetchConfigJson } from './JsonParser';
|
||||
import { ConfigJsonError, fetchConfigJson, isMissingResource } from './JsonParser';
|
||||
import { NitroLogger } from './NitroLogger';
|
||||
|
||||
export const DEFAULT_TIERS = [ 'core', 'custom', 'seasonal' ] as const;
|
||||
export type GamedataTier = typeof DEFAULT_TIERS[number] | string;
|
||||
@@ -28,51 +29,69 @@ const joinUrl = (base: string, path: string): string =>
|
||||
return `${ cleanBase }${ cleanPath }`;
|
||||
};
|
||||
|
||||
const tryFetchOrNull = async <T = any>(url: string): Promise<T | null> =>
|
||||
// Returns the parsed payload when the manifest exists, null on a clean 404.
|
||||
// Re-throws on any other error (network failure, 5xx, parse error) so callers
|
||||
// don't silently skip a tier because of a typo in manifest.json5.
|
||||
const tryFetchManifest = async <T = any>(url: string): Promise<T | null> =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return await fetchConfigJson<T>(url);
|
||||
}
|
||||
catch
|
||||
catch(err)
|
||||
{
|
||||
return null;
|
||||
if(isMissingResource(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Try .json5 first, then .json — both treated as optional. Anything other
|
||||
// than 404 on either bubbles up.
|
||||
const tryFetchManifestPair = async <T = any>(baseUrl: string, name: string): Promise<T | null> =>
|
||||
{
|
||||
const json5 = await tryFetchManifest<T>(joinUrl(baseUrl, `${ name }.json5`));
|
||||
if(json5 !== null) return json5;
|
||||
|
||||
return await tryFetchManifest<T>(joinUrl(baseUrl, `${ name }.json`));
|
||||
};
|
||||
|
||||
const isPlainObject = (value: any): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const arrayItemsLookKeyed = (arr: any[], idKeys: readonly string[]): string | null =>
|
||||
const arrayItemsLookKeyed = (arr: any[], idKeys: readonly string[], sourceLabel?: string): string | null =>
|
||||
{
|
||||
if(!arr.length) return null;
|
||||
|
||||
for(const key of idKeys)
|
||||
{
|
||||
let allHave = true;
|
||||
let have = 0;
|
||||
|
||||
for(const item of arr)
|
||||
{
|
||||
if(!isPlainObject(item) || item[key] === undefined || item[key] === null)
|
||||
{
|
||||
allHave = false;
|
||||
break;
|
||||
}
|
||||
if(isPlainObject(item) && item[key] !== undefined && item[key] !== null) have++;
|
||||
}
|
||||
|
||||
if(allHave) return key;
|
||||
if(have === arr.length) return key;
|
||||
|
||||
// Heuristic: if most items are keyed but a few are not, the data is
|
||||
// probably keyed and the outliers are bugs in the source data.
|
||||
// Surface this so operators don't get silent duplicates after merge.
|
||||
if(have > 0 && have / arr.length >= 0.8)
|
||||
{
|
||||
NitroLogger.warn(`mergeGamedata: ${ sourceLabel ? `${ sourceLabel }: ` : '' }array looks keyed by "${ key }" (${ have }/${ arr.length } items) but some entries are missing it — falling back to concat which may produce duplicates`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAULT_ID_KEYS): any =>
|
||||
export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAULT_ID_KEYS, sourceLabel?: string): any =>
|
||||
{
|
||||
if(b === undefined) return a;
|
||||
if(a === undefined) return b;
|
||||
|
||||
if(Array.isArray(a) && Array.isArray(b))
|
||||
{
|
||||
const idKey = arrayItemsLookKeyed(a, idKeys) || arrayItemsLookKeyed(b, idKeys);
|
||||
const idKey = arrayItemsLookKeyed(a, idKeys, sourceLabel) || arrayItemsLookKeyed(b, idKeys, sourceLabel);
|
||||
|
||||
if(!idKey) return a.concat(b);
|
||||
|
||||
@@ -92,7 +111,7 @@ export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAUL
|
||||
|
||||
if(at !== undefined)
|
||||
{
|
||||
out[at] = mergeGamedata(out[at], item, idKeys);
|
||||
out[at] = mergeGamedata(out[at], item, idKeys, sourceLabel);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -110,7 +129,7 @@ export const mergeGamedata = (a: any, b: any, idKeys: readonly string[] = DEFAUL
|
||||
|
||||
for(const k of Object.keys(b))
|
||||
{
|
||||
out[k] = mergeGamedata(a[k], b[k], idKeys);
|
||||
out[k] = mergeGamedata(a[k], b[k], idKeys, sourceLabel);
|
||||
}
|
||||
|
||||
return out;
|
||||
@@ -130,6 +149,11 @@ interface RootManifest
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
// Load every file in `files` concurrently, return them in the original
|
||||
// declared order so the merge step preserves override semantics.
|
||||
const fetchFilesInOrder = async (baseUrl: string, files: readonly string[]): Promise<any[]> =>
|
||||
Promise.all(files.map(file => fetchConfigJson(joinUrl(baseUrl, file))));
|
||||
|
||||
export const loadGamedata = async <T = any>(url: string, options: GamedataLoadOptions = {}): Promise<T> =>
|
||||
{
|
||||
if(!url) throw new Error('loadGamedata: empty URL');
|
||||
@@ -140,42 +164,47 @@ export const loadGamedata = async <T = any>(url: string, options: GamedataLoadOp
|
||||
}
|
||||
|
||||
const idKeys = options.mergeArrayIdKeys ?? DEFAULT_ID_KEYS;
|
||||
const rootManifest = await tryFetchOrNull<RootManifest>(joinUrl(url, 'manifest.json5'))
|
||||
?? await tryFetchOrNull<RootManifest>(joinUrl(url, 'manifest.json'));
|
||||
const rootManifest = await tryFetchManifestPair<RootManifest>(url, 'manifest');
|
||||
|
||||
const tiers = (rootManifest?.tiers && rootManifest.tiers.length)
|
||||
? rootManifest.tiers
|
||||
: (options.tiers ?? DEFAULT_TIERS);
|
||||
|
||||
// Fetch root-level files in parallel with discovering each tier's
|
||||
// manifest. Per-tier file batches stay sequenced relative to each other
|
||||
// so override order (core → custom → seasonal) is preserved during
|
||||
// merge, but fetches inside a tier batch run concurrently.
|
||||
const [ rootParts, tierManifests ] = await Promise.all([
|
||||
rootManifest?.files?.length ? fetchFilesInOrder(url, rootManifest.files) : Promise.resolve([] as any[]),
|
||||
Promise.all(tiers.map(async tier =>
|
||||
{
|
||||
const tierUrl = joinUrl(url, `${ tier }/`);
|
||||
const manifest = await tryFetchManifestPair<TierManifest>(tierUrl, 'manifest');
|
||||
|
||||
return { tier, tierUrl, manifest };
|
||||
}))
|
||||
]);
|
||||
|
||||
let merged: any = undefined;
|
||||
|
||||
if(rootManifest?.files?.length)
|
||||
for(const part of rootParts)
|
||||
{
|
||||
for(const file of rootManifest.files)
|
||||
merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys, url);
|
||||
}
|
||||
|
||||
for(const { tier, tierUrl, manifest } of tierManifests)
|
||||
{
|
||||
if(!manifest?.files?.length) continue;
|
||||
|
||||
const parts = await fetchFilesInOrder(tierUrl, manifest.files);
|
||||
|
||||
for(const part of parts)
|
||||
{
|
||||
const fileUrl = joinUrl(url, file);
|
||||
const part = await fetchConfigJson(fileUrl);
|
||||
merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys);
|
||||
merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys, `${ url } (${ tier })`);
|
||||
}
|
||||
}
|
||||
|
||||
for(const tier of tiers)
|
||||
{
|
||||
const tierUrl = joinUrl(url, `${ tier }/`);
|
||||
const tierManifest = await tryFetchOrNull<TierManifest>(joinUrl(tierUrl, 'manifest.json5'))
|
||||
?? await tryFetchOrNull<TierManifest>(joinUrl(tierUrl, 'manifest.json'));
|
||||
|
||||
if(!tierManifest?.files?.length) continue;
|
||||
|
||||
for(const file of tierManifest.files)
|
||||
{
|
||||
const fileUrl = joinUrl(tierUrl, file);
|
||||
const part = await fetchConfigJson(fileUrl);
|
||||
merged = (merged === undefined) ? part : mergeGamedata(merged, part, idKeys);
|
||||
}
|
||||
}
|
||||
|
||||
if(merged === undefined) throw new Error(`loadGamedata: directory mode at "${ url }" produced no data — make sure at least one tier (core/custom/seasonal) has a manifest.json5 with a 'files' array`);
|
||||
if(merged === undefined) throw new ConfigJsonError(`loadGamedata: directory mode at "${ url }" produced no data — make sure at least one tier (core/custom/seasonal) has a manifest.json5 with a 'files' array`, 'fetch', url);
|
||||
|
||||
return merged as T;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,28 @@ declare const __NITRO_JSON_MODE__: 'legacy' | 'json5' | 'auto' | undefined;
|
||||
const JSON5_EXTENSION = /\.json5(?:[?#]|$)/i;
|
||||
const JSON5_MIME = /(?:application|text)\/(?:json5|x-json5)/i;
|
||||
|
||||
export type ConfigJsonErrorPhase = 'fetch' | 'parse';
|
||||
|
||||
export class ConfigJsonError extends Error
|
||||
{
|
||||
public readonly phase: ConfigJsonErrorPhase;
|
||||
public readonly sourceUrl: string;
|
||||
public readonly httpStatus?: number;
|
||||
|
||||
constructor(message: string, phase: ConfigJsonErrorPhase, sourceUrl: string, httpStatus?: number, cause?: unknown)
|
||||
{
|
||||
super(message);
|
||||
this.name = 'ConfigJsonError';
|
||||
this.phase = phase;
|
||||
this.sourceUrl = sourceUrl;
|
||||
this.httpStatus = httpStatus;
|
||||
if(cause !== undefined) (this as any).cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
export const isMissingResource = (err: unknown): boolean =>
|
||||
err instanceof ConfigJsonError && err.phase === 'fetch' && err.httpStatus === 404;
|
||||
|
||||
const resolveJsonMode = (): 'legacy' | 'json5' | 'auto' =>
|
||||
{
|
||||
try
|
||||
@@ -44,9 +66,7 @@ const formatStrictError = (sourceUrl: string, err: unknown): string =>
|
||||
|
||||
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 : '';
|
||||
const trimmed = text ?? '';
|
||||
const mode = resolveJsonMode();
|
||||
|
||||
if(mode === 'legacy')
|
||||
@@ -57,7 +77,7 @@ export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''):
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
throw new Error(formatStrictError(sourceUrl, err));
|
||||
throw new ConfigJsonError(formatStrictError(sourceUrl, err), 'parse', sourceUrl, undefined, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +89,7 @@ export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''):
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
throw new Error(formatParseError(sourceUrl, err, err));
|
||||
throw new ConfigJsonError(formatParseError(sourceUrl, err, err), 'parse', sourceUrl, undefined, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +110,7 @@ export const parseConfigJson = <T = any>(text: string, sourceUrl: string = ''):
|
||||
}
|
||||
catch(json5Error)
|
||||
{
|
||||
throw new Error(formatParseError(sourceUrl, strictError, json5Error));
|
||||
throw new ConfigJsonError(formatParseError(sourceUrl, strictError, json5Error), 'parse', sourceUrl, undefined, json5Error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -109,7 +129,7 @@ export const parseConfigJsonFromResponse = async <T = any>(response: Response, s
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
throw new Error(formatParseError(url, err, err));
|
||||
throw new ConfigJsonError(formatParseError(url, err, err), 'parse', url, undefined, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,9 +138,23 @@ export const parseConfigJsonFromResponse = async <T = any>(response: Response, s
|
||||
|
||||
export const fetchConfigJson = async <T = any>(url: string, init?: RequestInit): Promise<T> =>
|
||||
{
|
||||
const response = await fetch(url, init);
|
||||
let response: Response | undefined;
|
||||
|
||||
if(!response || response.status !== 200) throw new Error(`Failed to fetch "${ url }" — server returned HTTP ${ response?.status ?? 'no response' }`);
|
||||
try
|
||||
{
|
||||
response = await fetch(url, init);
|
||||
}
|
||||
catch(networkErr)
|
||||
{
|
||||
const message = (networkErr as Error)?.message || String(networkErr);
|
||||
throw new ConfigJsonError(`Network error fetching "${ url }" — ${ message }`, 'fetch', url, undefined, networkErr);
|
||||
}
|
||||
|
||||
if(!response || response.status !== 200)
|
||||
{
|
||||
const status = response?.status;
|
||||
throw new ConfigJsonError(`Failed to fetch "${ url }" — server returned HTTP ${ status ?? 'no response' }`, 'fetch', url, status);
|
||||
}
|
||||
|
||||
return parseConfigJsonFromResponse<T>(response, url);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user