|
| 1 | +--- |
| 2 | +title: Bot Bridge Runtime Modes |
| 3 | +description: | |
| 4 | + Understand how BotBridge works in direct and sharded runtimes. |
| 5 | + Design endpoint contracts that stay stable regardless of Discord process topology. |
| 6 | +order: 6 |
| 7 | +category: '@spraxium/http' |
| 8 | +--- |
| 9 | + |
| 10 | +## Why the bridge exists |
| 11 | + |
| 12 | +*BotBridge* is the abstraction layer between HTTP controllers and Discord operations. Controllers should depend on *BotBridge* methods, not on raw discord.js internals like `client.guilds.cache` or `guild.members.ban()`. This keeps route behavior stable even when your deployment topology changes from a single process to sharded workers, because the controller code does not change. |
| 13 | + |
| 14 | +Without the bridge, moving from direct mode to sharded mode would require rewriting every controller that interacts with Discord. The bridge pattern isolates that difference into two concrete classes, *DirectBotBridge* and *ShardedBotBridge*, both implementing the same abstract contract. *BridgeFactory* selects the correct implementation at server startup and injects it into the DI dependency map, so controllers and services can declare `BotBridge` as a constructor parameter and receive the right implementation automatically. |
| 15 | + |
| 16 | +The bridge also forces serialization at the boundary. Discord.js objects contain circular references, live caches, and methods that are not safe to serialize to JSON. The bridge returns plain typed objects like *SerializedGuild*, *SerializedMember*, and *SerializedBan* that are safe for HTTP transport. |
| 17 | + |
| 18 | +## Runtime mode selection |
| 19 | + |
| 20 | +*BridgeFactory.create(client, manager)* selects the bridge implementation based on which runtime reference is available. The factory follows a strict precedence: *ShardingManager* takes priority over *Client*. If neither is provided, the factory throws an error at startup, preventing the server from running without Discord connectivity. |
| 21 | + |
| 22 | +<Table variant="striped"> |
| 23 | + <TableHead> |
| 24 | + <TableRow> |
| 25 | + <TableCell header="Mode">Mode</TableCell> |
| 26 | + <TableCell header="Implementation">Implementation</TableCell> |
| 27 | + <TableCell header="How it accesses Discord">How it accesses Discord</TableCell> |
| 28 | + <TableCell header="When to use">When to use</TableCell> |
| 29 | + </TableRow> |
| 30 | + </TableHead> |
| 31 | + <TableBody> |
| 32 | + <TableRow> |
| 33 | + <TableCell>Direct</TableCell> |
| 34 | + <TableCell>*DirectBotBridge*</TableCell> |
| 35 | + <TableCell>Uses the local Discord *Client* instance. Reads from guild cache and calls API methods directly on the same process.</TableCell> |
| 36 | + <TableCell>Single-process bots or development environments where sharding is unnecessary.</TableCell> |
| 37 | + </TableRow> |
| 38 | + <TableRow> |
| 39 | + <TableCell>Sharded</TableCell> |
| 40 | + <TableCell>*ShardedBotBridge*</TableCell> |
| 41 | + <TableCell>Uses `manager.broadcastEval()` to send a function to all shards. Returns the first non-null (reads) or first `true` (actions) result.</TableCell> |
| 42 | + <TableCell>Production bots in multiple shards where the HTTP server runs in the parent process without a local *Client*.</TableCell> |
| 43 | + </TableRow> |
| 44 | + </TableBody> |
| 45 | +</Table> |
| 46 | + |
| 47 | +## BotBridge capability contract |
| 48 | + |
| 49 | +The abstract *BotBridge* class defines 12 methods organized into three operation groups. Every concrete implementation must fulfill all 13 methods, ensuring controllers have a complete and consistent API regardless of runtime mode. |
| 50 | + |
| 51 | +<DropdownGroup> |
| 52 | +<Dropdown title="Read operations: getGuild · getMember · getMembers · getBan · getBans"> |
| 53 | + *getGuild(guildId)* returns a *SerializedGuild* with name, icon, member count, owner ID, creation date, and feature flags, or `null` if the bot is not in that guild. *getMember(guildId, userId)* returns a *SerializedMember* with roles, timeout state, and pending flag, while *getMembers(guildId)* returns all cached members as an array that may be incomplete if the guild cache has not been fully populated. *getBan(guildId, userId)* returns a *SerializedBan* with user snapshot and reason, or `null` if no ban exists; *getBans(guildId)* returns all ban records as an array and falls back to an empty array rather than `null` on failure. |
| 54 | +</Dropdown> |
| 55 | + |
| 56 | +<Dropdown title="Moderation: banMember · unbanMember · kickMember · timeoutMember · removeTimeoutMember"> |
| 57 | + *banMember(guildId, userId, reason, options?)* bans a member with an optional *deleteMessageSeconds* value to purge recent messages, returning `true` on success. *unbanMember(guildId, userId)* lifts an active ban and *kickMember(guildId, userId, reason)* removes the member without blocking them from rejoining, both returning `true` on success. *timeoutMember(guildId, userId, durationMs, reason)* applies a Discord communication timeout for the given duration in milliseconds, and *removeTimeoutMember(guildId, userId)* clears it by setting the timeout to `null`; both return `true` on success and `false` if the operation fails. |
| 58 | +</Dropdown> |
| 59 | + |
| 60 | +<Dropdown title="Role: addRole · removeRole"> |
| 61 | + *addRole(guildId, userId, roleId)* assigns the specified Discord role to the member and returns `true` on success. *removeRole(guildId, userId, roleId)* removes the role and also returns `true` on success. Both methods return `false` when the guild is unavailable, the member is not found, or the bot lacks the `MANAGE_ROLES` permission, and neither throws an exception. |
| 62 | +</Dropdown> |
| 63 | +</DropdownGroup> |
| 64 | + |
| 65 | +Read methods return serialized objects or `null`. Action methods return boolean success flags. Every bridge method wraps its body in a try/catch: on failure, read methods return `null` (or empty array for *getBans*) and action methods return `false`. This means controllers never need to handle unexpected exceptions from bridge calls. |
| 66 | + |
| 67 | +## How the sharded bridge works |
| 68 | + |
| 69 | +*ShardedBotBridge* uses `manager.broadcastEval()` to execute a closure on every shard in the cluster. The closure receives the shard's local *Client* and a serialized context object with the parameters (guild ID, user ID, etc). Only the shard that owns the target guild will find it in its cache, so the others return `null` or `false`. |
| 70 | + |
| 71 | +For read operations, the bridge collects results from all shards and returns the first non-null value using `.find(r => r !== null)`. For action operations, it returns `true` if any shard returned `true` using `.some(r => r === true)`. This fan-out pattern means every operation reaches the correct shard without requiring the HTTP server to know shard topology or guild-to-shard mapping. |
| 72 | + |
| 73 | +The trade-off is latency: every bridge call in sharded mode pays the cost of inter-process communication to all shards. For bots with many shards, consider caching guild-to-shard mappings if read-heavy endpoints become a bottleneck. |
| 74 | + |
| 75 | +## Controller consuming the bridge |
| 76 | + |
| 77 | +<CodeTabs defaultIndex={0}> |
| 78 | + <Tab label="http/controllers/moderation-http.controller.ts" language="typescript"> |
| 79 | +```typescript filename="http/controllers/moderation-http.controller.ts" |
| 80 | +import { HttpController, HttpGet, HttpPost, HttpParam, HttpBody, HttpStatus } from '@spraxium/http'; |
| 81 | +import { BotBridge, NotFoundError } from '@spraxium/http'; |
| 82 | +import { BanMemberDto } from '../dto/ban-member.dto'; |
| 83 | + |
| 84 | +@HttpController('/guilds/:guildId/moderation') |
| 85 | +export class ModerationHttpController { |
| 86 | + constructor(private readonly bridge: BotBridge) {} |
| 87 | + |
| 88 | + @HttpGet('/bans') |
| 89 | + async listBans(@HttpParam('guildId') guildId: string) { |
| 90 | + const bans = await this.bridge.getBans(guildId); |
| 91 | + return { ok: true, guildId, bans, count: bans.length }; |
| 92 | + } |
| 93 | + |
| 94 | + @HttpGet('/members/:userId') |
| 95 | + async getMember( |
| 96 | + @HttpParam('guildId') guildId: string, |
| 97 | + @HttpParam('userId') userId: string, |
| 98 | + ) { |
| 99 | + const member = await this.bridge.getMember(guildId, userId); |
| 100 | + if (!member) throw new NotFoundError('Member not found in guild'); |
| 101 | + return { ok: true, member }; |
| 102 | + } |
| 103 | + |
| 104 | + @HttpPost('/bans/:userId') |
| 105 | + @HttpStatus(201) |
| 106 | + async banMember( |
| 107 | + @HttpParam('guildId') guildId: string, |
| 108 | + @HttpParam('userId') userId: string, |
| 109 | + @HttpBody(BanMemberDto) body: BanMemberDto, |
| 110 | + ) { |
| 111 | + const success = await this.bridge.banMember( |
| 112 | + guildId, |
| 113 | + userId, |
| 114 | + body.reason, |
| 115 | + { deleteMessageSeconds: body.deleteMessageSeconds }, |
| 116 | + ); |
| 117 | + return { ok: success, guildId, userId }; |
| 118 | + } |
| 119 | +} |
| 120 | +``` |
| 121 | + </Tab> |
| 122 | + <Tab label="http/dto/ban-member.dto.ts" language="typescript"> |
| 123 | +```typescript filename="http/dto/ban-member.dto.ts" |
| 124 | +import { IsInt, IsOptional, IsString, Max, Min, MinLength } from 'class-validator'; |
| 125 | + |
| 126 | +export class BanMemberDto { |
| 127 | + @IsString() |
| 128 | + @MinLength(3) |
| 129 | + reason!: string; |
| 130 | + |
| 131 | + @IsOptional() |
| 132 | + @IsInt() |
| 133 | + @Min(0) |
| 134 | + @Max(604800) |
| 135 | + deleteMessageSeconds?: number; |
| 136 | +} |
| 137 | +``` |
| 138 | + </Tab> |
| 139 | + <Tab label="src/app.module.ts" language="typescript"> |
| 140 | +```typescript filename="src/app.module.ts" |
| 141 | +import { Module } from '@spraxium/common'; |
| 142 | +import { HttpClientModule, HttpModule } from '@spraxium/http'; |
| 143 | +import { ModerationHttpController } from './http/controllers/moderation-http.controller'; |
| 144 | + |
| 145 | +@HttpClientModule({ |
| 146 | + controllers: [ModerationHttpController], |
| 147 | +}) |
| 148 | +@Module({ |
| 149 | + imports: [HttpModule], |
| 150 | + providers: [ModerationHttpController], |
| 151 | +}) |
| 152 | +export class AppModule {} |
| 153 | +``` |
| 154 | + </Tab> |
| 155 | +</CodeTabs> |
| 156 | + |
| 157 | +## Serialization model |
| 158 | + |
| 159 | +The bridge uses three serializer classes to convert discord.js runtime objects into plain typed payloads safe for JSON transport. These serializers strip circular references, convert dates to ISO strings, and extract only the fields that external clients need. The serialized types serve as the stable API boundary: controllers return these types, and clients consume them. |
| 160 | + |
| 161 | +<Table variant="striped"> |
| 162 | + <TableHead> |
| 163 | + <TableRow> |
| 164 | + <TableCell header="Serializer">Serializer</TableCell> |
| 165 | + <TableCell header="Input">Input</TableCell> |
| 166 | + <TableCell header="Output fields">Output fields</TableCell> |
| 167 | + </TableRow> |
| 168 | + </TableHead> |
| 169 | + <TableBody> |
| 170 | + <TableRow> |
| 171 | + <TableCell>*GuildSerializer*</TableCell> |
| 172 | + <TableCell>discord.js *Guild*</TableCell> |
| 173 | + <TableCell>*id*, *name*, *icon*, *memberCount*, *ownerId*, *createdAt* (ISO string), *features* (string array).</TableCell> |
| 174 | + </TableRow> |
| 175 | + <TableRow> |
| 176 | + <TableCell>*MemberSerializer*</TableCell> |
| 177 | + <TableCell>discord.js *GuildMember*</TableCell> |
| 178 | + <TableCell>*id*, *username*, *displayName*, *discriminator*, *avatar*, *bot*, *joinedAt*, *createdAt*, *roles* (array of *SerializedRole* with id/name/color/position/permissions/managed/mentionable), *pending*, *communicationDisabledUntil*.</TableCell> |
| 179 | + </TableRow> |
| 180 | + <TableRow> |
| 181 | + <TableCell>*BanSerializer*</TableCell> |
| 182 | + <TableCell>discord.js *GuildBan*</TableCell> |
| 183 | + <TableCell>*user* (nested *SerializedUser* with id/username/discriminator/avatar/bot/createdAt), *reason* (string or `null`).</TableCell> |
| 184 | + </TableRow> |
| 185 | + </TableBody> |
| 186 | +</Table> |
| 187 | + |
| 188 | +In direct mode, serializers run in the same process as the Discord client. In sharded mode, the `broadcastEval` closure performs inline serialization on the shard that owns the guild, returning plain objects that cross the IPC boundary safely. Both paths produce the same output shape, so controller code is identical regardless of mode. |
| 189 | + |
| 190 | +## Bridge endpoint design checklist |
| 191 | + |
| 192 | +<Steps> |
| 193 | + <Step title="Design for mode transparency"> |
| 194 | + Keep controller logic identical in direct and sharded deployments by depending only on *BotBridge* methods. Mode selection should remain a configuration concern in `defineHttp({ sharding })`, not a controller concern. Never import *DirectBotBridge* or *ShardedBotBridge* directly in controller code. |
| 195 | + </Step> |
| 196 | + <Step title="Return serialized payloads only"> |
| 197 | + Treat serializer output types as your API boundary. Never return discord.js runtime objects from handlers because they contain circular references and live caches that break JSON serialization. If you need fields not covered by the default serializers, create a custom serializer rather than exposing raw discord.js objects. |
| 198 | + </Step> |
| 199 | + <Step title="Handle nullable reads explicitly"> |
| 200 | + Read operations can return `null` when entities are missing or unavailable in the current runtime state. Convert `null` outcomes into clear HTTP responses based on your route contract. A `null` guild typically maps to *NotFoundError*(404), while a `null` member may warrant *NotFoundError* or a filtered empty response depending on the endpoint semantics. |
| 201 | + </Step> |
| 202 | + <Step title="Keep mutations idempotent when possible"> |
| 203 | + Action methods return booleans and may run in distributed contexts where retries happen. Endpoint semantics should tolerate retries without side effects. For example, banning an already-banned user returns `false` but does not create a conflict, and the controller can still report the operation outcome without throwing. |
| 204 | + </Step> |
| 205 | + <Step title="Validate mutation payloads with DTOs"> |
| 206 | + Use *@HttpBody(DtoClass)* with *class-validator* constraints on every mutation endpoint. Bridge actions like *banMember* and *timeoutMember* accept parameters that should be validated before reaching the bridge layer. DTO validation catches malformed input early and produces structured error responses automatically. |
| 207 | + </Step> |
| 208 | +</Steps> |
| 209 | + |
| 210 | +<Callout tone="warning" title="Bridge methods never throw exceptions on failure"> |
| 211 | + All bridge methods wrap operations in try/catch internally. Read methods return `null` on failure, and action methods return `false`. Design your controller error handling around these return values rather than expecting thrown exceptions from bridge calls. |
| 212 | +</Callout> |
| 213 | + |
| 214 | +## Next page |
| 215 | + |
| 216 | +Continue with [Exceptions and Error Handling](/guide/http-exceptions-and-error-handling). |
0 commit comments