Versions
@wolfstar/plugin-subcommands-advanced@2.0.3
@wolfstar/plugin-i18next@2.0.2
@wolfstar/http-framework@3.6.0
@discordjs/builders@1.14.1
@sapphire/shapeshift@4.0.0
- Node
24.21.0
Symptom
In a consumer bot, every subcommand registered with @RegisterAsSubcommandGroup whose builder uses applyLocalizedBuilder (from plugin-i18next) fails to load. The store logs, for each one:
Error when loading '/app/dist/commands/subscriptions/twitch/add.mjs':
ExpectedConstraintError > s.string().regex()
Received: 'addName'
at applyNameLocalizedBuilder (@wolfstar/plugin-i18next)
at applyLocalizedBuilder (@wolfstar/plugin-i18next)
at parseSlashSubcommand (plugin-subcommands-advanced/src/lib/utils/functions.ts:30:12)
This is not fatal — the client still reaches Ready — but in the case that surfaced this, 5 of 7 subcommands across the affected parent commands failed to load, so the bot starts with a chunk of its command tree missing.
What was already ruled out on the consumer side: the translation keys and files are correct. The commands/twitch namespace exists for every locale the bot loads, with every key (addName, addDescription, …), and it resolves correctly as soon as client.load() has finished. t("commands/twitch:addName") called during piece construction returns the raw, unresolved key; the exact same call after client.load() returns "add" with the same locale.
Root cause: confirmed mechanism
Checked directly against the packages' source (wolfstar-project/plugins@db9e75a for the two plugins, wolfstar-project/stars-components@cde63d4 for http-framework — both match the reported versions exactly, no drift on main).
1. parseSlashSubcommand invokes a function-form builder immediately, synchronously, when called:
|
export function parseSlashSubcommand( |
|
subcommand: SlashSubcommandResolvable, |
|
): SlashCommandSubcommandBuilder { |
|
if (typeof subcommand === "function") { |
|
const builder = new SlashCommandSubcommandBuilder(); |
|
return subcommand(builder, container) ?? builder; |
|
} |
export function parseSlashSubcommand(
subcommand: SlashSubcommandResolvable,
): SlashCommandSubcommandBuilder {
if (typeof subcommand === "function") {
const builder = new SlashCommandSubcommandBuilder();
return subcommand(builder, container) ?? builder;
}
...
2. RegisterAsSubcommandGroup calls parseSlashSubcommand inside the decorated class's constructor, before super() runs — i.e. while the piece is being constructed by the store loader:
|
return function decorate<T extends CommandConstructor>(target: T): T { |
|
const decorated = class extends target { |
|
public constructor(...args: any[]) { |
|
const [context, baseOptions = {}] = args as [ |
|
FrameworkCommand.LoaderContext, |
|
FrameworkCommand.Options, |
|
]; |
|
const parsed = parseSlashSubcommand(slashSubcommand); |
|
const name = |
|
container.client?.options.subcommandsAdvanced?.nameCommandsAutogenerated === true |
|
? `${parentCommandName}/${groupName}/${parsed.name}` |
|
: baseOptions.name; |
|
|
|
super(context, { ...baseOptions, name }); |
|
analyzeSubcommandGroupParsed(this, parentCommandName, groupName, slashSubcommand); |
|
} |
|
} as T; |
const decorated = class extends target {
public constructor(...args: any[]) {
const [context, baseOptions = {}] = args as [...];
const parsed = parseSlashSubcommand(slashSubcommand); // <-- builder callback runs here
const name =
container.client?.options.subcommandsAdvanced?.nameCommandsAutogenerated === true
? `${parentCommandName}/${groupName}/${parsed.name}`
: baseOptions.name;
super(context, { ...baseOptions, name });
analyzeSubcommandGroupParsed(this, parentCommandName, groupName, slashSubcommand);
}
};
Note this isn't arbitrary — parsed.name is needed synchronously to build the autogenerated piece name passed to super() a few lines later when subcommandsAdvanced.nameCommandsAutogenerated is on (as it is in the reproducing config), so the eager call looks intentional rather than an oversight. RegisterAsSubcommand has the identical pattern.
3. By contrast, @RegisterCommand (from http-framework itself) never calls the builder at decoration or construction time — it only stores the raw function, and the function is invoked lazily, later, from toJSON():
https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/http-framework/src/lib/interactions/decorators/RegisterCommand.ts#L26-L30
export function RegisterCommand<Options extends Command.Options = Command.Options>(data: ChatInputCommandResolver.CommandData) {
return createClassDecorator(function decorate(target: typeof Command<Options>) {
ensureChatInputCommandResolver(target).setCommand(data);
});
}
setCommand just stores the reference:
https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/http-framework/src/lib/interactions/resolvers/ChatInputCommandResolver.ts#L31-L34
and the function form is only actually called from #normalizeCommand, itself only reachable through #resolve() → toJSON():
https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/http-framework/src/lib/interactions/resolvers/ChatInputCommandResolver.ts#L66-L81
public toJSON(): ChatInputCommandResolver.ResolvedCommand {
return (this.#data ??= this.#resolve());
}
#resolve(): ChatInputCommandResolver.ResolvedCommand {
const command = this.#normalizeCommand(this.#commandData);
...
This is presumably called at command-sync/registration time, well after the whole store has finished loading — which is exactly why the parent command and /info (both using @RegisterCommand with applyLocalizedBuilder) load fine while subcommands registered through plugin-subcommands-advanced don't.
4. applyNameLocalizedBuilder / applyDescriptionLocalizedBuilder resolve translations synchronously the instant they're called, via getLocalizedData → container.i18n's already-loaded languages map:
|
export function applyNameLocalizedBuilder< |
|
T extends BuilderWithName, |
|
const TOpt extends TOptions = TOptions, |
|
Ns extends Namespace = AnyNamespace, |
|
KPrefix = undefined, |
|
>(builder: T, key: ParseKeys<Ns, TOpt, KPrefix>) { |
|
const result = getLocalizedData(key); |
|
return builder.setName(result.value).setNameLocalizations(result.localizations); |
|
} |
So: whenever parseSlashSubcommand runs a function-form builder that calls applyLocalizedBuilder, container.i18n needs to already be fully initialized at that exact moment. For RegisterAsSubcommandGroup that moment is piece construction time, during store loading.
Open question — not yet confirmed, needs investigation
This is the part I could not pin down from the source, and it's more surprising than it first looks: Client#load() fully awaits every registered PreLoad hook — including plugin-i18next's await container.i18n.init() — before it ever calls container.stores.load():
https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/http-framework/src/lib/Client.ts#L142-L153
public async load(options: LoadOptions = {}) {
for (const plugin of Client.plugins.values(PluginHook.PreLoad)) {
await plugin.hook.call(this, this.options);
this.emit(Events.PluginLoaded, plugin.type, plugin.name);
}
...
await container.stores.load();
...
and plugin-i18next's preLoad hook is exactly await container.i18n.init():
|
public static async [preLoad](this: Client): Promise<void> { |
|
await container.i18n.init(); |
|
} |
InternationalizationHandler#init calls i18next.init() with initImmediate: false, load: "all", ns: <every discovered namespace>, preload: <every discovered language>:
|
public async init() { |
|
const { namespaces, languages } = await this.walkRootDirectory(this.languagesDirectory); |
|
const userOptions = isFunction(this.options.i18next) |
|
? this.options.i18next(namespaces, languages) |
|
: this.options.i18next; |
|
const ignoreJSONStructure = userOptions?.ignoreJSONStructure ?? false; |
|
const skipOnVariables = userOptions?.interpolation?.skipOnVariables ?? false; |
|
|
|
i18next.use(Backend); |
|
await i18next.init({ |
|
backend: this.backendOptions, |
|
fallbackLng: this.options.defaultName ?? "en-US", |
|
initImmediate: false, |
|
interpolation: { |
|
escapeValue: false, |
|
...userOptions?.interpolation, |
|
skipOnVariables, |
|
}, |
|
load: "all", |
|
defaultNS: this.options.defaultNS ?? "default", |
|
ns: namespaces, |
|
preload: languages, |
|
...userOptions, |
|
ignoreJSONStructure, |
|
}); |
|
|
|
this.namespaces = new Set(namespaces); |
|
for (const item of languages) { |
|
this.languages.set(item, i18next.getFixedT(item)); |
|
} |
|
this.languagesLoaded = true; |
and @wolfstar/i18next-backend's read() does correctly wait on its async readPaths() before invoking the callback when initImmediate is falsy:
https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/i18next-backend/src/index.ts#L16-L30
So on paper, by the time container.stores.load() — and therefore any piece constructor — runs, i18next.init() should have already resolved for every namespace/language pair, including commands/twitch. That contradicts the observed symptom, and I don't have an explanation for the gap. Things worth checking that are outside what I verified here:
- Whether
i18next.init()'s returned promise genuinely only resolves once every ns×preload combination has been read by the backend, or whether it can resolve early in some configuration (e.g. only for the default namespace) while other namespaces finish loading asynchronously afterward.
- Whether
plugin-subcommands-advanced's custom SubcommandsAdvancedLoaderStrategy — which replaces the CommandStore's loader strategy from a postInitialization hook that runs synchronously inside the Client constructor, i.e. before load()/PreLoad even starts — changes how or when pieces under it get constructed relative to the default @sapphire/pieces strategy.
- Whether
container.stores.load()'s own file-discovery/import step can start constructing pieces before the preceding await in Client#load() has actually settled, in some interleaving that isn't obvious from reading the two functions in isolation.
I'm flagging this as an open question rather than asserting a cause, since I could not reproduce it directly — only trace the mechanism through the source.
Suggested minimal repro
A parent command with @RegisterCommand + a child with @RegisterAsSubcommandGroup, both using applyLocalizedBuilder, with subcommandsAdvanced: { nameCommandsAutogenerated: true } set on the client options. If useful, I can help put this together as an actual runnable repro.
Possible direction (not prescriptive)
Given @RegisterCommand already demonstrates a fully-lazy pattern (store the raw builder, resolve it only at toJSON() time), having RegisterAsSubcommandGroup/RegisterAsSubcommand defer parseSlashSubcommand the same way — resolving the name lazily too, or resolving it earlier via a dedicated pre-store i18n-ready hook — would probably sidestep this whole class of timing issue regardless of what the exact race turns out to be. That said, the eager call exists for a reason (the synchronously-needed name for super()), so there may be constraints I'm not seeing that ruled this out already.
Versions
@wolfstar/plugin-subcommands-advanced@2.0.3@wolfstar/plugin-i18next@2.0.2@wolfstar/http-framework@3.6.0@discordjs/builders@1.14.1@sapphire/shapeshift@4.0.024.21.0Symptom
In a consumer bot, every subcommand registered with
@RegisterAsSubcommandGroupwhose builder usesapplyLocalizedBuilder(fromplugin-i18next) fails to load. The store logs, for each one:This is not fatal — the client still reaches
Ready— but in the case that surfaced this, 5 of 7 subcommands across the affected parent commands failed to load, so the bot starts with a chunk of its command tree missing.What was already ruled out on the consumer side: the translation keys and files are correct. The
commands/twitchnamespace exists for every locale the bot loads, with every key (addName,addDescription, …), and it resolves correctly as soon asclient.load()has finished.t("commands/twitch:addName")called during piece construction returns the raw, unresolved key; the exact same call afterclient.load()returns"add"with the same locale.Root cause: confirmed mechanism
Checked directly against the packages' source (
wolfstar-project/plugins@db9e75afor the two plugins,wolfstar-project/stars-components@cde63d4forhttp-framework— both match the reported versions exactly, no drift onmain).1.
parseSlashSubcommandinvokes a function-form builder immediately, synchronously, when called:plugins/packages/plugin-subcommands-advanced/src/lib/utils/functions.ts
Lines 25 to 31 in db9e75a
2.
RegisterAsSubcommandGroupcallsparseSlashSubcommandinside the decorated class's constructor, beforesuper()runs — i.e. while the piece is being constructed by the store loader:plugins/packages/plugin-subcommands-advanced/src/lib/utils/decorators.ts
Lines 80 to 96 in db9e75a
Note this isn't arbitrary —
parsed.nameis needed synchronously to build the autogenerated piecenamepassed tosuper()a few lines later whensubcommandsAdvanced.nameCommandsAutogeneratedis on (as it is in the reproducing config), so the eager call looks intentional rather than an oversight.RegisterAsSubcommandhas the identical pattern.3. By contrast,
@RegisterCommand(fromhttp-frameworkitself) never calls the builder at decoration or construction time — it only stores the raw function, and the function is invoked lazily, later, fromtoJSON():https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/http-framework/src/lib/interactions/decorators/RegisterCommand.ts#L26-L30
setCommandjust stores the reference:https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/http-framework/src/lib/interactions/resolvers/ChatInputCommandResolver.ts#L31-L34
and the function form is only actually called from
#normalizeCommand, itself only reachable through#resolve()→toJSON():https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/http-framework/src/lib/interactions/resolvers/ChatInputCommandResolver.ts#L66-L81
This is presumably called at command-sync/registration time, well after the whole store has finished loading — which is exactly why the parent command and
/info(both using@RegisterCommandwithapplyLocalizedBuilder) load fine while subcommands registered throughplugin-subcommands-advanceddon't.4.
applyNameLocalizedBuilder/applyDescriptionLocalizedBuilderresolve translations synchronously the instant they're called, viagetLocalizedData→container.i18n's already-loadedlanguagesmap:plugins/packages/plugin-i18next/src/lib/functions.ts
Lines 398 to 406 in db9e75a
So: whenever
parseSlashSubcommandruns a function-form builder that callsapplyLocalizedBuilder,container.i18nneeds to already be fully initialized at that exact moment. ForRegisterAsSubcommandGroupthat moment is piece construction time, during store loading.Open question — not yet confirmed, needs investigation
This is the part I could not pin down from the source, and it's more surprising than it first looks:
Client#load()fully awaits every registeredPreLoadhook — includingplugin-i18next'sawait container.i18n.init()— before it ever callscontainer.stores.load():https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/http-framework/src/lib/Client.ts#L142-L153
and
plugin-i18next'spreLoadhook is exactlyawait container.i18n.init():plugins/packages/plugin-i18next/src/register.ts
Lines 40 to 42 in db9e75a
InternationalizationHandler#initcallsi18next.init()withinitImmediate: false,load: "all",ns: <every discovered namespace>,preload: <every discovered language>:plugins/packages/plugin-i18next/src/lib/InternationalizationHandler.ts
Lines 122 to 152 in db9e75a
and
@wolfstar/i18next-backend'sread()does correctly wait on its asyncreadPaths()before invoking the callback wheninitImmediateis falsy:https://github.com/wolfstar-project/stars-components/blob/cde63d45d0f90a7d4ee4d5279e558f088546e9fa/packages/i18next-backend/src/index.ts#L16-L30
So on paper, by the time
container.stores.load()— and therefore any piece constructor — runs,i18next.init()should have already resolved for every namespace/language pair, includingcommands/twitch. That contradicts the observed symptom, and I don't have an explanation for the gap. Things worth checking that are outside what I verified here:i18next.init()'s returned promise genuinely only resolves once everyns×preloadcombination has been read by the backend, or whether it can resolve early in some configuration (e.g. only for the default namespace) while other namespaces finish loading asynchronously afterward.plugin-subcommands-advanced's customSubcommandsAdvancedLoaderStrategy— which replaces theCommandStore's loader strategy from apostInitializationhook that runs synchronously inside theClientconstructor, i.e. beforeload()/PreLoadeven starts — changes how or when pieces under it get constructed relative to the default@sapphire/piecesstrategy.container.stores.load()'s own file-discovery/import step can start constructing pieces before the precedingawaitinClient#load()has actually settled, in some interleaving that isn't obvious from reading the two functions in isolation.I'm flagging this as an open question rather than asserting a cause, since I could not reproduce it directly — only trace the mechanism through the source.
Suggested minimal repro
A parent command with
@RegisterCommand+ a child with@RegisterAsSubcommandGroup, both usingapplyLocalizedBuilder, withsubcommandsAdvanced: { nameCommandsAutogenerated: true }set on the client options. If useful, I can help put this together as an actual runnable repro.Possible direction (not prescriptive)
Given
@RegisterCommandalready demonstrates a fully-lazy pattern (store the raw builder, resolve it only attoJSON()time), havingRegisterAsSubcommandGroup/RegisterAsSubcommanddeferparseSlashSubcommandthe same way — resolving the name lazily too, or resolving it earlier via a dedicated pre-store i18n-ready hook — would probably sidestep this whole class of timing issue regardless of what the exact race turns out to be. That said, the eager call exists for a reason (the synchronously-needednameforsuper()), so there may be constraints I'm not seeing that ruled this out already.