@nexcord/core
v0.0.2
Published
<div align="center">
Readme
Nexcord
A decorator-driven, dependency-injection-powered framework for building Discord bots with discord.js and TypeScript — built for Bun.
Nexcord takes the ergonomics of decorator/DI frameworks like NestJS or Angular and applies them to Discord bot development. Instead of wiring up event listeners, command handlers, and services by hand, you describe what each class is with a decorator, and Nexcord handles discovery, instantiation, and dependency injection for you.
@Service()
export class GreetingService {
greet(username: string) {
return `Welcome aboard, ${username}! 🎉`;
}
}
@Listener()
export class MemberEvents {
constructor(private readonly greeting: GreetingService) {}
@On('guildMemberAdd')
async onJoin(member: GuildMember) {
await member.send(this.greeting.greet(member.user.username));
}
}No manual new GreetingService(), no manual client.on('guildMemberAdd', ...) wiring, no manual file imports. Nexcord discovers both classes, resolves the dependency, and boots them for you.
Table of Contents
- Features
- The Nexcord Ecosystem
- Installation
- Quick Start
- Recommended Project Structure
- Core Concepts
- Beta Status & Known Limitations
- Contributing
- License
✨ Features
- Decorator-based architecture —
@Service(),@Listener(),@SlashCommand(), and@Guard()describe your classes; Nexcord does the rest. - Recursive constructor injection — powered by
reflect-metadata. Services can depend on other services, which can depend on more services, resolved automatically and instantiated as singletons. - Convention-based autoloading — drop a file in the right folder and it's registered. No manual import lists to maintain.
- Route Guard Forwarding — a type-safe, pattern-matching middleware system that can gate any command or event, including wildcards, prefix/suffix matches, and regular expressions.
- Built-in services for common bot needs: channel lookups, guild lookups, role caching, and shared configuration.
- A real ecosystem, not just a core —
@nexcord/tsxlets you write components as JSX, and@nexcord/configgives you a DI-aware shared config store. - Built for Bun — no build step required to run your bot in development.
🧩 The Nexcord Ecosystem
Nexcord is split into small, focused packages. @nexcord/core is the only one you strictly need — the others are optional but designed to plug in seamlessly.
| Package | Description |
| --- | --- |
| @nexcord/core | The framework itself — bootstrapping, dependency injection, decorators, guards, and built-in services. You are here. |
| @nexcord/tsx | Write buttons, modals, embeds, and Discord Components V2 layouts as JSX instead of chaining builder methods. |
| @nexcord/config | A tiny, dependency-injection-aware shared config store that ConfigService reads from — with helpers to register config that depends on your services or on the bot being ready. |
You can add either companion package at any point — neither is required to get a bot running.
📦 Installation
Nexcord is developed for and with Bun, and its autoloading layer is built on standard CommonJS-style require() calls, so a Node.js + ts-node/tsx setup works as well.
bun add @nexcord/core discord.js reflect-metadatanpm install @nexcord/core discord.js reflect-metadata
pnpm add @nexcord/core discord.js reflect-metadata
yarn add @nexcord/core discord.js reflect-metadataNexcord's decorators rely on TypeScript's experimental decorator metadata, so make sure your tsconfig.json enables it:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true
}
}🚀 Quick Start
Create your bot's entry point and bootstrap it with NexCord:
// src/index.ts
import 'reflect-metadata';
import { GatewayIntentBits } from 'discord.js';
import { NexCord } from '@nexcord/core';
NexCord
.setIntents(
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers
)
.run(process.env.DISCORD_TOKEN!);DISCORD_TOKEN=your-bot-token bun run src/index.tsThat's it — run() recursively loads your commands, events, configs, and common folders, registers everything it finds, and logs in.
Need to run logic once the bot is actually online — with services already resolved for you? Pass a factory:
import { NexCord } from '@nexcord/core';
import { GuildService } from '@nexcord/core';
NexCord.run(process.env.DISCORD_TOKEN!, {
inject: [GuildService],
factory: async (guilds: GuildService) => {
console.log(`Logged in as ${NexCord.client.user?.tag}`);
}
});📁 Recommended Project Structure
Nexcord doesn't force a rigid structure on you, but its autoloader looks for a small set of conventional folders under src/. A typical project looks like this:
src/
├── commands/ # @SlashCommand() classes — autoloaded
│ ├── Ping.command.ts
│ └── Info.command.ts
├── events/ # @Listener() classes — autoloaded
│ ├── Ready.listener.ts
│ └── Member.listener.ts
├── configs/ # registerConfig() definitions (@nexcord/config) — autoloaded
│ └── general.config.ts
├── common/ # Shared building blocks — autoloaded
│ ├── services/ # @Service() classes
│ │ └── Greeting.service.ts
│ ├── guards/ # @Guard() classes
│ │ └── Cooldown.guard.ts
│ └── actions/ # @nexcord/tsx component action controllers
│ └── Confirm.action.ts
├── components/ # .tsx component definitions (@nexcord/tsx)
│ └── confirm.components.tsx
└── index.ts # Bootstrap entry pointFour top-level folders are autoloaded automatically (see DirEnum below): commands, events, configs, and common. Anything else — services, guards, components, or any structure you prefer — works fine as long as it's imported, directly or indirectly, from a file that is autoloaded. Decorators run the moment a class's module is evaluated, so a @Service() class gets registered as soon as anything imports it, regardless of which folder it physically lives in.
🧠 Core Concepts
Bootstrapping — the NexCord class
NexCord is a static entry point that controls your bot's lifecycle, imported from @nexcord/core:
| Method | Description |
| --- | --- |
| NexCord.setIntents(...intents) | Overrides the default GatewayIntentBits (Nexcord enables all intents by default). |
| NexCord.setClient(client) | Provide your own pre-configured discord.js Client instance instead of letting Nexcord create one. |
| NexCord.setGuild(guildId) | Resolves and caches a target Guild globally, handy for single-guild bots. |
| NexCord.get(targetClass) | Retrieves a registered @Service() / @Listener() / @SlashCommand() / @Guard() instance from the DI cache. |
| NexCord.run(token, options?) | Recursively loads your project folders, registers everything, and logs in. Accepts an optional { factory, inject } pair — see below. |
| NexCord.client | The underlying discord.js Client, available once the bot has bootstrapped. |
setIntents, setClient, and setGuild are chainable, so you configure the bot fluently before calling run().
Autoloading & Directory Conventions
When NexCord.run() is called, its loader walks four directories (relative to your project root) and require()s every TypeScript file it finds, which is what triggers your decorators to run and register themselves:
| Directory | Purpose |
| --- | --- |
| src/commands | Slash command classes |
| src/events | Event listener classes |
| src/configs | Configuration registration files |
| src/common | Everything shared — services, guards, actions, and anything else |
Dependency Injection
Any class decorated with @Service(), @Listener(), @SlashCommand(), or @Guard() participates in Nexcord's DI container. Constructor parameters are inspected via reflect-metadata, and their dependencies are resolved recursively: if A depends on B, and B depends on C, Nexcord instantiates C first, injects it into B, then injects B into A — all as singletons, so every consumer shares the same instance.
import { Service } from '@nexcord/core';
@Service()
export class QuoteService {
private readonly quotes = [
'Ship it.',
'Works on my machine.',
'It is always a race condition.'
];
random(): string {
return this.quotes[Math.floor(Math.random() * this.quotes.length)]!;
}
}Any other @Service(), @Listener(), @SlashCommand(), or @Guard() class can now simply ask for a QuoteService in its constructor and receive a fully resolved instance.
Services
Services are where your business logic belongs — keep commands and listeners thin, and let services do the heavy lifting. Just decorate a class with @Service(); there's no interface to implement.
Slash Commands
A slash command is a class decorated with @SlashCommand(name, description) that extends BaseSlashCommand and implements two hooks:
build(builder)— add options, subcommands, or permissions. Return the builder (sync or async).handler(interaction)— runs when the command is invoked.
// src/commands/Quote.command.ts
import { BaseSlashCommand, SlashCommand } from '@nexcord/core';
import {
CommandInteraction,
SlashCommandBuilder,
type SlashCommandOptionsOnlyBuilder
} from 'discord.js';
import { QuoteService } from '../common/services/Quote.service';
@SlashCommand('quote', 'Sends a random developer quote.')
export class QuoteCommand extends BaseSlashCommand {
constructor(private readonly quotes: QuoteService) {
super();
}
override build(b: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder) {
return b;
}
override async handler(interaction: CommandInteraction): Promise<void> {
await interaction.reply(this.quotes.random());
}
}Notice QuoteService is simply requested in the constructor — Nexcord resolves and injects it automatically.
Event Listeners
Event listeners are classes decorated with @Listener(). Inside, individual methods are decorated with @On(event) (fires every time) or @Once(event) (fires once).
// src/events/Member.listener.ts
import { Listener, On } from '@nexcord/core';
import { GuildMember } from 'discord.js';
import { GreetingService } from '../common/services/Greeting.service';
@Listener()
export class MemberEvents {
constructor(private readonly greeting: GreetingService) {}
@On('guildMemberAdd')
async onJoin(member: GuildMember) {
await member.send(this.greeting.greet(member.user.username)).catch(() => {});
}
}Beta note: Nexcord also ships parameter decorators —
@Content(),@Author(),@Member(),@Guild(),@GetClient()— meant to let you pull specific values straight into your method's parameters instead of destructuring the raw event object yourself. In the current beta, these decorators register their metadata correctly, but the invocation loop that would actually extract and inject those values is still a work in progress. Write your handlers to accept the raw discord.js event arguments for now (as in the example above), and treat the parameter decorators as a forward-compatible preview of where the API is headed.
Guards & Route Forwarding
Guards are Nexcord's middleware system, and its most distinctive feature. A @Guard() class declares one or more forwarding routes that connect a guard method to a listener method. If the guard method returns (or resolves to) false, the target's execution is aborted before it ever runs.
@Guard(listeners, ...routes: Forward<T, Ls>[])listeners— an object mapping a name to a listener (or other registered) class, e.g.{ MemberEvents }.routes— one or more strings of the form"guardMethod->ListenerName::listenerMethod". TypeScript validates this format at compile time via theForward<T, Ls>type — if the target method doesn't exist, your build fails.
Route strings support pattern matching, so a single guard can protect many targets at once:
| Pattern | Matches | Example |
| --- | --- | --- |
| * | Anything, in any position | checkAuth->*::* — every method of every listener |
| prefix% | Names starting with prefix | on% matches onMessage, onReady |
| %suffix | Names ending with suffix | %Listener matches MemberEvents (if aliased accordingly) |
| %contains% | Names containing contains anywhere | %check% matches checkSpam |
| /regex/ | Names matching a regular expression | /^isAdmin/ matches isAdminUser |
// src/common/guards/Cooldown.guard.ts
import { Guard } from '@nexcord/core';
import { Message } from 'discord.js';
import { HypeListener } from '../../events/Hype.listener';
@Guard(
{ HypeListener },
'throttle->HypeListener::onMessage'
)
export class CooldownGuard {
private readonly lastTrigger = new Map<string, number>();
async throttle(message: Message): Promise<boolean> {
const last = this.lastTrigger.get(message.author.id) ?? 0;
if (Date.now() - last < 10_000) return false; // still cooling down — abort
this.lastTrigger.set(message.author.id, Date.now());
return true;
}
}Guard forwarding fires both when a listener's event is dispatched and when a slash command is invoked, so the same mechanism can be used to gate permission checks on commands as well as events.
Built-in Services
Nexcord ships a handful of @Service() classes out of the box, ready to be injected wherever you need them:
| Service | Purpose |
| --- | --- |
| ChannelService | Fast lookup and validation helpers for text, voice, DM, and "sendable" channels. |
| ConfigService | A dot-notation reader/writer (get<T>(path) / set(path, value)) over the shared store from @nexcord/config. |
| GuildService | Simple guild lookup helper, complementing NexCord.setGuild(). |
| RoleService | Loads a guild's roles and caches them into config as roles.<name> / roles.<id>. Exposes helpers like hasUser(userId, ...roles). |
⚠️ Beta Status & Known Limitations
Nexcord is beta software. The public API described above is stable enough to build on, but expect rough edges:
- Parameter decorators (
@Content(),@Author(),@Member(),@Guild(),@GetClient()) are registered but not yet wired into the event invocation loop — see the Event Listeners section above. - APIs may still shift between minor releases while the framework stabilizes toward a
1.0.
Found a bug or a rough edge? Please open an issue — feedback at this stage directly shapes the 1.0 API.
🤝 Contributing
Issues and pull requests are welcome. If you're proposing a larger change (a new decorator, a change to the DI resolution order, etc.), please open an issue first to discuss the approach.
📄 License
Released under the MIT License.
Built by SignorMassimo · Part of the Nexcord ecosystem alongside @nexcord/tsx and @nexcord/config
