From 8e3b5de2494be36fd53ed3979739e8549105c1e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C3=A8sAbhi?= <43380238+tresabhi@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:06:26 -0500 Subject: [PATCH] feat: use decorators --- packages/website-ue/src/core/api/abstract.ts | 146 ++------ packages/website-ue/src/core/api/client.ts | 91 ++++- packages/website-ue/src/core/api/server.ts | 349 +++++++++--------- .../website-ue/src/hooks/useGameStrings.ts | 4 +- .../api/game-strings/[locale]/[group].json.ts | 4 +- 5 files changed, 278 insertions(+), 316 deletions(-) diff --git a/packages/website-ue/src/core/api/abstract.ts b/packages/website-ue/src/core/api/abstract.ts index 0e49e7a1a..82bf8c8fe 100644 --- a/packages/website-ue/src/core/api/abstract.ts +++ b/packages/website-ue/src/core/api/abstract.ts @@ -13,16 +13,23 @@ import type { Tanks } from "../../protos/tanks"; export abstract class AbstractAPI { abstract tankList(): Promise; abstract tank(id: string): Promise; + abstract tanks(): Promise; + abstract popularTanks(): Promise; - private _tanksCache: Tanks | undefined; - protected abstract _tanks(): Promise; - async tanks() { - if (this._tanksCache === undefined) { - this._tanksCache = await this._tanks(); - } + abstract strings(locale: string): Promise; + abstract gameStrings(locale: string): Promise>; + protected abstract _groupedGameStrings( + locale: string, + group: string, + ): Promise>; + abstract gameStringGroups(): Promise; - return this._tanksCache; - } + abstract avatar(id: string): Promise; + abstract avatars(): Promise; + abstract avatarList(): Promise; + + abstract background(id: string): Promise; + abstract backgrounds(): Promise; private assertLocale(locale: string) { const supported = locales.supported.find( @@ -36,137 +43,26 @@ export abstract class AbstractAPI { return supported; } - private _stringsCache: Record = {}; - protected abstract _strings(locale: string): Promise; - async strings(locale: string) { - this.assertLocale(locale); - - if (this._stringsCache[locale] === undefined) { - this._stringsCache[locale] = await this._strings(locale); - } - - return this._stringsCache[locale]; - } - - private _gameStringsCache: Record> = {}; - protected abstract _gameStrings( - locale: string, - ): Promise>; - async gameStrings(locale: string) { - this.assertLocale(locale); - - if (this._gameStringsCache[locale] === undefined) { - this._gameStringsCache[locale] = await this._gameStrings(locale); - } - - return this._gameStringsCache[locale]; - } - - private _groupedGameStringsCache: Record< - string, - Record> - > = {}; - protected abstract _groupedGameStrings( - locale: string, - group: string, - ): Promise>; + @Cache<[string, string, boolean]>((...args) => args.join("-")) async groupedGameStrings(locale: string, group: string, prefix: boolean) { this.assertLocale(locale); - if (this._groupedGameStringsCache[locale] === undefined) { - this._groupedGameStringsCache[locale] = {}; - } + const gameStrings = await this._groupedGameStrings(locale, group); - if (this._groupedGameStringsCache[locale][group] === undefined) { - this._groupedGameStringsCache[locale][group] = - await this._groupedGameStrings(locale, group); - } - - if (!prefix) return this._groupedGameStringsCache[locale][group]; + if (!prefix) return gameStrings; const strings: Record = {}; - for (const key in this._groupedGameStringsCache[locale][group]) { - strings[`${group}__${key}`] = - this._groupedGameStringsCache[locale][group][key]; + for (const key in gameStrings) { + strings[`${group}__${key}`] = gameStrings[key]; } return strings; } - - private _avatarCache: Record = {}; - protected abstract _avatar(id: string): Promise; - async avatar(id: string) { - if (this._avatarCache[id] === undefined) { - this._avatarCache[id] = await this._avatar(id); - } - - return this._avatarCache[id]; - } - - private _avatarsCache: Avatars | undefined; - protected abstract _avatars(): Promise; - async avatars() { - if (this._avatarsCache === undefined) { - this._avatarsCache = await this._avatars(); - } - - return this._avatarsCache; - } - - private _avatarListCache: AvatarList | undefined; - protected abstract _avatarList(): Promise; - async avatarList() { - if (this._avatarListCache === undefined) { - this._avatarListCache = await this._avatarList(); - } - - return this._avatarListCache; - } - - private _backgroundCache: Record = {}; - protected abstract _background(id: string): Promise; - async background(id: string) { - if (this._backgroundCache[id] === undefined) { - this._backgroundCache[id] = await this._background(id); - } - - return this._backgroundCache[id]; - } - - private _backgroundsCache: Backgrounds | undefined; - protected abstract _backgrounds(): Promise; - async backgrounds() { - if (this._backgroundsCache === undefined) { - this._backgroundsCache = await this._backgrounds(); - } - - return this._backgroundsCache; - } - - private _gameStringGroupsCache: string[] | undefined; - protected abstract _gameStringGroups(): Promise; - async gameStringGroups() { - if (this._gameStringGroupsCache === undefined) { - this._gameStringGroupsCache = await this._gameStringGroups(); - } - - return this._gameStringGroupsCache; - } - - private _popularTanksCache: PopularTanks | undefined; - protected abstract _popularTanks(): Promise; - async popularTanks() { - if (this._popularTanksCache === undefined) { - this._popularTanksCache = await this._popularTanks(); - } - - return this._popularTanksCache; - } } export function Cache( - discriminator: (...args: Arguments) => string = () => "", + discriminator: (...args: Arguments) => string = (...args) => args.join("-"), ) { const cache = new WeakMap>(); diff --git a/packages/website-ue/src/core/api/client.ts b/packages/website-ue/src/core/api/client.ts index cf1c75060..804ed45ec 100644 --- a/packages/website-ue/src/core/api/client.ts +++ b/packages/website-ue/src/core/api/client.ts @@ -3,34 +3,89 @@ import type { Strings } from "@blitzkit/i18n"; import { Avatars } from "../../protos/avatars"; import { PopularTanks } from "../../protos/popular_tanks"; import { Tank } from "../../protos/tank"; -import { AbstractAPI } from "./abstract"; +import { AbstractAPI, Cache } from "./abstract"; + +const rejected = Promise.reject("Not implemented on client"); export class ClientAPI extends AbstractAPI { - protected _avatars() { - return fetchPB("/api/avatars/all.pb", Avatars); + @UnimplementedOnClient + tankList() { + return rejected; } - protected _gameStrings(): Promise> { - throw new Error("Cannot fetch full game strings on client"); + @Cache() + tank(id: string) { + return fetchPB(`/api/tanks/${id}.pb`, Tank); } - protected async _groupedGameStrings(locale: string, group: string) { - const response = await fetch(`/api/game-strings/${locale}/${group}.json`); - const json = (await response.json()) as Record; - return json; + @UnimplementedOnClient + tanks() { + return rejected; } - protected async _strings(locale: string) { - const response = await fetch(`/api/strings/${locale}.json`); - const json = (await response.json()) as Strings; - return json; - } - - protected _popularTanks() { + @Cache() + popularTanks() { return fetchPB("/api/tanks/popular.pb", PopularTanks); } - protected _tank(id: string) { - return fetchPB(`/api/tanks/${id}.pb`, Tank); + @Cache() + async strings(locale: string) { + const response = await fetch(`/api/strings/${locale}.json`); + const json = (await response.json()) as Strings; + + return json; + } + + @UnimplementedOnClient + gameStrings() { + return rejected; + } + + @Cache() + protected async _groupedGameStrings(locale: string, group: string) { + const response = await fetch(`/api/game-strings/${locale}/${group}.json`); + const json = (await response.json()) as Record; + + return json; + } + + @UnimplementedOnClient + gameStringGroups() { + return rejected; + } + + @UnimplementedOnClient + avatar() { + return rejected; + } + + @UnimplementedOnClient + avatars() { + return fetchPB("/api/avatars/all.pb", Avatars); + } + + @UnimplementedOnClient + avatarList() { + return rejected; + } + + @UnimplementedOnClient + background() { + return rejected; + } + + @UnimplementedOnClient + backgrounds() { + return rejected; } } + +function UnimplementedOnClient( + _target: unknown, + propertyKey: string, + descriptor: PropertyDescriptor, +) { + descriptor.value = function () { + throw new Error(`${propertyKey} is not implemented on the client`); + }; +} diff --git a/packages/website-ue/src/core/api/server.ts b/packages/website-ue/src/core/api/server.ts index c6c5dfa36..8c7933117 100644 --- a/packages/website-ue/src/core/api/server.ts +++ b/packages/website-ue/src/core/api/server.ts @@ -35,104 +35,6 @@ export class ServerAPI extends AbstractAPI { super(); } - protected async _gameStrings(locale: string) { - const group = this.metadata.group("ClientConfigsEntity"); - - if (group.length !== 1) { - throw new RangeError( - `Don't know how to handle ${group.length} ClientConfigsEntities`, - ); - } - - const clientConfig = this.metadata.item(group[0].id); - const localizationResources = clientConfig.LocalizationResources(); - - if (!localizationResources.remote_storage) { - throw new Error("Localization resources not found"); - } - - let remoteStorage: RemoteStorageComponent | undefined = undefined; - - for (const candidateId of localizationResources.remote_storage - .remote_urls) { - const item = this.metadata.item(candidateId); - const candidate = item.RemoteStorage(); - - if ( - remoteStorage === undefined || - candidate.download_speed_weight > remoteStorage.download_speed_weight - ) { - remoteStorage = candidate; - } - } - - if (remoteStorage === undefined) { - throw new Error("No suitable production remote storage found"); - } - - const strings: Record = {}; - const configPath = `${remoteStorage.url}/${remoteStorage.relative_path}/config.yaml`; - - try { - const config = await fetch(configPath) - .then((response) => response.text()) - .then((text) => parse(text) as LocalizationConfig); - - for (const namespace of config.namespaces) { - Object.assign( - strings, - await fetch( - `${remoteStorage.url}${remoteStorage.relative_path}/${namespace}/${locale}.yaml`, - ) - .then((response) => response.text()) - .then((text) => { - const strings: Record = {}; - const parsed = parse(text) as Record; - - for (const key in parsed) { - strings[key] = parsed[key].replaceAll('\\"', '"'); - } - - return strings; - }), - ); - } - } catch (error) { - console.warn( - `Failed to fetch config from ${configPath}. Returning empty strings. This will throw an error in production. Error:`, - error, - ); - } - - return strings; - } - - protected async _groupedGameStrings(locale: string, group: string) { - const strings = await this.gameStrings(locale); - const filtered: Record = {}; - - for (const key in strings) { - if (key.startsWith(`${group}__`)) { - const trimmed = key.substring(group.length + 2); - filtered[trimmed] = strings[key]; - } - } - - return filtered; - } - - protected async _strings(locale: string) { - const localized = (await globbedStrings[ - `../../../../i18n/strings/${locale}.json` - ]()) as DeepPartial; - const defaults = (await globbedStrings[ - `../../../../i18n/strings/${locales.default}.json` - ]()) as Strings; - const strings = merge({}, defaults, localized); - - return strings; - } - @Cache() async tankList() { const group = this.metadata.group("TankEntity"); @@ -157,18 +59,7 @@ export class ServerAPI extends AbstractAPI { return { list }; } - protected async _tanks() { - const tankList = await this.tankList(); - const data: Tanks = { tanks: {} }; - - for (const { id } of tankList.list) { - data.tanks[id] = await this.tank(id); - } - - return data; - } - - @Cache<[string]>((id) => id) + @Cache() async tank(id: string) { const tankList = await this.tankList(); const tankListEntry = tankList.list.find((tank) => tank.id === id); @@ -185,70 +76,20 @@ export class ServerAPI extends AbstractAPI { return { id, slug, tank, compensation } satisfies Tank; } - protected async _avatars() { - const avatars: Avatar[] = []; + @Cache() + async tanks() { + const tankList = await this.tankList(); + const data: Tanks = { tanks: {} }; - for (const item of this.metadata.group("ProfileAvatarEntity")) { - avatars.push(await this.avatar(item.name)); + for (const { id } of tankList.list) { + data.tanks[id] = await this.tank(id); } - return { avatars }; + return data; } - protected async _avatarList() { - const avatars = await this.avatars(); - const list = avatars.avatars.map((avatar) => avatar.name); - - return { list }; - } - - protected async _avatar(id: string) { - const avatar = this.metadata.item(`ProfileAvatarEntity.${id}`); - const name = avatar.name; - const stuff_ui = avatar.StuffUI("UIComponent"); - const profile_avatar = avatar.ProfileAvatar(); - const sellable = avatar.components.sellableComponent - ? avatar.Sellable() - : undefined; - - return { name, stuff_ui, profile_avatar, sellable }; - } - - protected async _backgrounds() { - const backgrounds: Background[] = []; - - for (const item of this.metadata.group("ProfileBackgroundEntity")) { - backgrounds.push(await this.background(item.name)); - } - - return { backgrounds }; - } - - protected async _background(id: string) { - const background = this.metadata.item(`ProfileBackgroundEntity.${id}`); - const name = background.name; - const stuff_ui = background.StuffUI("UIComponent"); - const profile_background = background.ProfileBackground(); - const sellable = background.components.sellableComponent - ? background.Sellable() - : undefined; - - return { name, stuff_ui, profile_background, sellable }; - } - - protected async _gameStringGroups() { - const defaultGameStrings = await this.gameStrings(locales.default); - const groups = new Set(); - - for (const key in defaultGameStrings) { - const [group] = key.split("__"); - groups.add(group); - } - - return Array.from(groups.values()); - } - - protected async _popularTanks() { + @Cache() + async popularTanks() { if (!existsSync("../../temp/popular.json")) { const { tanks } = await this.tanks(); const tanksArray = Object.values(tanks); @@ -332,4 +173,174 @@ export class ServerAPI extends AbstractAPI { const data = await readFile("../../temp/popular.json", "utf-8"); return JSON.parse(data) as PopularTanks; } + + @Cache() + async strings(locale: string) { + const localized = (await globbedStrings[ + `../../../../i18n/strings/${locale}.json` + ]()) as DeepPartial; + const defaults = (await globbedStrings[ + `../../../../i18n/strings/${locales.default}.json` + ]()) as Strings; + const strings = merge({}, defaults, localized); + + return strings; + } + + @Cache() + async gameStrings(locale: string) { + const group = this.metadata.group("ClientConfigsEntity"); + + if (group.length !== 1) { + throw new RangeError( + `Don't know how to handle ${group.length} ClientConfigsEntities`, + ); + } + + const clientConfig = this.metadata.item(group[0].id); + const localizationResources = clientConfig.LocalizationResources(); + + if (!localizationResources.remote_storage) { + throw new Error("Localization resources not found"); + } + + let remoteStorage: RemoteStorageComponent | undefined = undefined; + + for (const candidateId of localizationResources.remote_storage + .remote_urls) { + const item = this.metadata.item(candidateId); + const candidate = item.RemoteStorage(); + + if ( + remoteStorage === undefined || + candidate.download_speed_weight > remoteStorage.download_speed_weight + ) { + remoteStorage = candidate; + } + } + + if (remoteStorage === undefined) { + throw new Error("No suitable production remote storage found"); + } + + const strings: Record = {}; + const configPath = `${remoteStorage.url}/${remoteStorage.relative_path}/config.yaml`; + + try { + const config = await fetch(configPath) + .then((response) => response.text()) + .then((text) => parse(text) as LocalizationConfig); + + for (const namespace of config.namespaces) { + Object.assign( + strings, + await fetch( + `${remoteStorage.url}${remoteStorage.relative_path}/${namespace}/${locale}.yaml`, + ) + .then((response) => response.text()) + .then((text) => { + const strings: Record = {}; + const parsed = parse(text) as Record; + + for (const key in parsed) { + strings[key] = parsed[key].replaceAll('\\"', '"'); + } + + return strings; + }), + ); + } + } catch (error) { + console.warn( + `Failed to fetch config from ${configPath}. Returning empty strings. This will throw an error in production. Error:`, + error, + ); + } + + return strings; + } + + @Cache() + protected async _groupedGameStrings(locale: string, group: string) { + const strings = await this.gameStrings(locale); + const filtered: Record = {}; + + for (const key in strings) { + if (key.startsWith(`${group}__`)) { + const trimmed = key.substring(group.length + 2); + filtered[trimmed] = strings[key]; + } + } + + return filtered; + } + + @Cache() + async gameStringGroups() { + const defaultGameStrings = await this.gameStrings(locales.default); + const groups = new Set(); + + for (const key in defaultGameStrings) { + const [group] = key.split("__"); + groups.add(group); + } + + return Array.from(groups.values()); + } + + @Cache() + async avatar(id: string) { + const avatar = this.metadata.item(`ProfileAvatarEntity.${id}`); + const name = avatar.name; + const stuff_ui = avatar.StuffUI("UIComponent"); + const profile_avatar = avatar.ProfileAvatar(); + const sellable = avatar.components.sellableComponent + ? avatar.Sellable() + : undefined; + + return { name, stuff_ui, profile_avatar, sellable }; + } + + @Cache() + async avatars() { + const avatars: Avatar[] = []; + + for (const item of this.metadata.group("ProfileAvatarEntity")) { + avatars.push(await this.avatar(item.name)); + } + + return { avatars }; + } + + @Cache() + async avatarList() { + const avatars = await this.avatars(); + const list = avatars.avatars.map((avatar) => avatar.name); + + return { list }; + } + + @Cache() + async background(id: string) { + const background = this.metadata.item(`ProfileBackgroundEntity.${id}`); + const name = background.name; + const stuff_ui = background.StuffUI("UIComponent"); + const profile_background = background.ProfileBackground(); + const sellable = background.components.sellableComponent + ? background.Sellable() + : undefined; + + return { name, stuff_ui, profile_background, sellable }; + } + + @Cache() + async backgrounds() { + const backgrounds: Background[] = []; + + for (const item of this.metadata.group("ProfileBackgroundEntity")) { + backgrounds.push(await this.background(item.name)); + } + + return { backgrounds }; + } } diff --git a/packages/website-ue/src/hooks/useGameStrings.ts b/packages/website-ue/src/hooks/useGameStrings.ts index 13f94047c..d9fa61256 100644 --- a/packages/website-ue/src/hooks/useGameStrings.ts +++ b/packages/website-ue/src/hooks/useGameStrings.ts @@ -5,8 +5,8 @@ import { useLocale } from "./useLocale"; export function useGameStrings(group: string) { const locale = useLocale(); const strings = useAwait( - () => api.groupedGameStrings(locale, group, true), - `game-strings-${locale}-${group}` + () => api._groupedGameStrings(locale, group, true), + `game-strings-${locale}-${group}`, ); return strings; } diff --git a/packages/website-ue/src/pages/api/game-strings/[locale]/[group].json.ts b/packages/website-ue/src/pages/api/game-strings/[locale]/[group].json.ts index 458d1262a..21e33d17e 100644 --- a/packages/website-ue/src/pages/api/game-strings/[locale]/[group].json.ts +++ b/packages/website-ue/src/pages/api/game-strings/[locale]/[group].json.ts @@ -20,10 +20,10 @@ export async function GET({ }: { params: { locale: string; group: string }; }) { - const strings = await api.groupedGameStrings( + const strings = await api._groupedGameStrings( params.locale, params.group, - false + false, ); return Response.json(strings); }