@jeremybui/flamework-nest
v0.6.0
Published
NestJS-flavored controllers, Flamework DI, request pipelines, modules, and networking adapters for roblox-ts.
Downloads
48
Readme
@jeremybui/flamework-nest
A NestJS-flavored server framework for roblox-ts: server controllers, client systems, Flamework-native providers, throttling, guards, pipes, interceptors, exception filters, lightweight modules, typed gateways, and explicit networking adapters.
Version 0.6.0 is early-stage. Pin it exactly and perform game-specific Studio, load, and security testing before production use.
Quick Start
Once the shared schema and root modules are imported, startup is one line per environment:
// server
bootstrap(AppModule, remotes);
// client
bootstrapClient(AppModule);createControllerRemoteSchema() registers its contracts and remembers its adapter in an internal side table. bootstrap() recovers both automatically. bootstrapClient() consumes the sole imported schema; pass { remotes } only when more than one schema exists.
Installation
For the fully supported Remo path:
npm install --save-exact @jeremybui/[email protected] @flamework/core @rbxts/remo @rbxts/t roblox-tsInstall @rbxts/bytenet or @rbxts/tether only when using those optional adapters.
Enable TypeScript decorators and Flamework's transformer. Because this release uses a personal npm scope, include that scope in typeRoots:
{
"compilerOptions": {
"experimentalDecorators": true,
"typeRoots": ["node_modules/@rbxts", "node_modules/@flamework", "node_modules/@jeremybui"],
"plugins": [{ "transform": "rbxts-transformer-flamework" }]
}
}Custom Rojo trees must map @jeremybui/flamework-nest/out alongside their @rbxts and @flamework dependencies. See the repository NAMING.md for the planned @rbxts/flamework-nest scope migration.
Core model
Shared ControllerContract values describe route names and verbs. Server @Controller classes provide reflected handlers. Client @System() classes are Flamework controllers under an unambiguous name. createControllerRemoteSchema creates transport-neutral handles, wireControllers binds handlers to those handles, and System(contract) or createControllerGateway exposes a typed client API.
GET routes are request/response calls returning Promise<RouteResult<T>>. POST, DELETE, and PATCH routes are fire-and-forget calls.
export type RouteResult<T> =
| { readonly ok: true; readonly value: T }
| {
readonly ok: false;
readonly error?: { readonly code: string; readonly message?: string };
};Default failures omit error, preventing exception details from crossing the remote boundary. An exception filter may deliberately return safe structured details.
Providers and Flamework DI
@Injectable()
export class InventoryService {
private readonly inventories = new Map<number, string[]>();
public getInventory(player: Player): string[] {
return this.inventories.get(player.UserId) ?? [];
}
}
@Controller("Inventory")
export class InventoryController {
public constructor(private readonly inventory: InventoryService) {}
@Get("GetPlayerInventory")
public getInventory(player: Player): string[] {
return this.inventory.getInventory(player);
}
}@Injectable does not register a separate container. It marks the class with Flamework singleton metadata. Flamework's transformer emits constructor parameter IDs, and Modding.resolveSingleton constructs the dependency graph.
Client systems
Use @System() instead of Flamework's raw @Controller() for ordinary client singletons. This package already uses @Controller() for server route handlers, so the separate client name keeps each side's role clear while preserving Flamework DI and lifecycle behavior.
import type { OnStart } from "@flamework/core";
import { System } from "@jeremybui/flamework-nest";
@System()
export class HudSystem implements OnStart {
public onStart(): void {
print("HUD ready");
}
}For an auto-wired gateway, associate the contract with its remote namespace type, register the remote root before ignition, and extend System(contract). The resulting methods are the existing ControllerGateway mapping, so there is no declaration merge or handwritten passthrough layer.
import type { OnStart } from "@flamework/core";
import {
bootstrapClient,
defineControllerContract,
Module,
System,
type FireRemoteHandle,
type RequestRemoteHandle,
type RouteResult,
} from "@jeremybui/flamework-nest";
interface CurrencyRemotes {
readonly getBalance: RequestRemoteHandle<[], RouteResult<number>>;
readonly addCoins: FireRemoteHandle<[amount: number]>;
}
export const currencyContract = defineControllerContract<CurrencyRemotes>()({
controllerName: "Currency",
routes: [
{ name: "GetBalance", verb: "GET" },
{ name: "AddCoins", verb: "POST" },
],
} as const);
@System()
export class CurrencySystem extends System(currencyContract) implements OnStart {
public onStart(): void {
this.addCoins(10);
this.getBalance().then((result) => {
if (result.ok) print(result.value);
});
}
}
@Module({ controllers: [CurrencySystem] })
class AppModule {}
bootstrapClient(AppModule, { remotes });Keep the literal @System() on every class that extends System(contract). Flamework's transformer discovers and emits application-site metadata for decorated consumer classes; an extends mixin expression alone cannot register the generated subclass or supply its constructor DI and lifecycle metadata. Internally, @System() marks the class as a Flamework singleton through Modding/Reflect rather than calling Flamework's built-in @Controller() decorator as a runtime function.
bootstrapClient registers remotes before constructing the client module and igniting Flamework. With one imported schema, the { remotes } override above can be omitted. Construction fails immediately if remotes were omitted or if the lower-camel controller namespace (for example, Currency → currency) is absent. Manual registerSystemRemotes and createControllerGateway calls remain supported.
Request pipeline
Pipeline decorators can be placed on a controller, a route, or both:
@Controller("Vehicle")
@UseFilters(VehicleExceptionFilter)
@UseInterceptors(VehicleLoggingInterceptor)
@Throttle({ limit: 20, windowSeconds: 1 })
export class VehicleController {
@Post("UseVehicle")
@Throttle({ limit: 5, windowSeconds: 1 })
@UseGuards(new IsAdminGuard())
@UsePipes(t.string)
public useVehicle(player: Player, vehicleId: string): void {
this.vehicleService.useVehicle(player, vehicleId);
}
}The composition order is fixed and enforced:
Throttle
→ Controller Guards
→ Method Guards
→ Controller Pipes
→ Method Pipes
→ Controller Interceptors (before)
→ Method Interceptors (before)
→ Handler
→ Method Interceptors (after)
→ Controller Interceptors (after)
→ Method Filters on throw
→ Controller Filters on throwController guards and pipes run before their method-level counterparts. Each pipe layer retains exact argument-count validation. Controller interceptors wrap method interceptors, and method filters receive exceptions before controller filters. A method @Throttle overrides the controller default instead of installing a second limiter. Decorator source order does not control pipeline order.
Throttle state is scoped to each (player, controller route) pair and removed on Players.PlayerRemoving. The implementation uses a sliding window of accepted-call timestamps rather than a fixed window. The small additional bookkeeping avoids the fixed-window boundary case where a client can send up to twice the configured limit across two adjacent windows.
When a limit is exceeded, a GET returns the normal { ok: false } RouteResult failure envelope. POST, DELETE, and PATCH routes have no reply channel, so the server silently drops those calls. Every over-limit call is rejected, but [Throttle] Rejected ... is emitted at most once per player, route, and windowSeconds interval so remote spam cannot become warning-log spam. A later violation can be logged again after that warning interval resets. Repeated violations do not escalate to kicks, bans, or callbacks in this phase; games that need those policies must implement them separately.
Provider classes can be passed to interceptor and filter decorators, allowing Flamework constructor injection:
@Injectable()
class LoggingInterceptor implements Interceptor {
public intercept(context: ExecutionContext, nextHandler: () => unknown): unknown {
print(`before ${context.controllerName}.${context.routeName}`);
const result = nextHandler();
print(`after ${context.controllerName}.${context.routeName}`);
return result;
}
}
@Injectable()
class SafeFilter implements ExceptionFilter {
public catch(_exception: unknown): RouteResult<unknown> {
return { ok: false, error: { code: "SAFE_FAILURE" } };
}
}
@Get("Example")
@UseInterceptors(LoggingInterceptor)
@UseFilters(SafeFilter)
public example(_player: Player): string {
return "ok";
}Throttling, guards, and pipes remain authoritative server-boundary checks. Typed gateways are developer ergonomics, not a security boundary.
Lightweight modules
@Module({
providers: [GreetingService],
exports: [GreetingService],
})
class GreetingModule {}
@Module({
imports: [GreetingModule],
controllers: [StatusController],
providers: [LoggingInterceptor, SafeFilter],
})
class StatusModule {}
@Module({ imports: [StatusModule] })
class AppModule {}
bootstrap(AppModule, remotes);bootstrapModule walks imports, validates duplicate ownership and export visibility, resolves listed controllers/providers with Flamework, and ignites Flamework. bootstrap performs that work before contract verification and controller wiring. A controller or provider cannot consume another module's provider unless the owner module is imported and exports it; startup fails with module and provider names otherwise.
Modules are organizational metadata over Flamework's flat singleton model. This release intentionally has no hierarchical child injectors, request/scoped providers, dynamic modules, or forRoot/forFeature APIs.
Advanced / Escape Hatches
bootstrap() internally performs module startup, contract resolution and optional Studio verification, adapter recovery, and controller wiring. Each low-level function remains supported when granular control is required:
bootstrapModule(AppModule);
assertControllerContractsConsistency(contractPairs);
wireControllers(remotes, { adapter: remoAdapter });Modules are optional. Existing applications can also keep Flamework path discovery:
Flamework.addPaths("src/server/services");
Flamework.addPaths("src/server/controllers");
Flamework.ignite();
assertControllerContractsConsistency(contractPairs);
wireControllers(remotes, { adapter: remoAdapter });Networking adapters
Schema creation and granular adapter call sites are explicit. bootstrap() reuses the adapter attached during schema creation unless it is overridden:
const remotes = createControllerRemoteSchema(contracts, { adapter });
bootstrap(AppModule, remotes);
// Equivalent granular escape hatch:
wireControllers(remotes, { adapter });
const gateway = createControllerGateway(contract, remotes.inventory, {
adapter,
});Remo
remoAdapter is the complete reference path and supports request and fire handles. The adapter internally materializes Remo remotes; do not wrap createControllerRemoteSchema in Remo.createRemotes.
BytNet
import ByteNet from "@rbxts/bytenet";
const adapter = createBytNetAdapter(ByteNet);The published @rbxts/bytenet 0.4 API supports send/listen packets only. The adapter supports mutation routes with reliable ByteNet.unknown tuple packets and fails immediately when a contract contains a GET route. This adapter is not Studio-verified.
Tether
const adapter = createTetherAdapter(messaging, {
bindings: {
"inventory.read": {
kind: "request",
message: Message.Read,
returnMessage: Message.ReadReturn,
},
"inventory.set": { kind: "fire", message: Message.Set },
},
});Tether uses transformer-generated message serialization and paired messages for simulated functions. Consumers must declare concrete message schemas with { args: [...] } request/event envelopes and { value: ... } return envelopes, then bind full lower-camel route names to numeric IDs. This adapter is not Studio-verified.
Contract consistency
assertControllerContractsConsistency compares each shared contract with reflected controller route names and verbs at startup. Contracts remain explicit; flamework-nest does not derive business contracts from TypeScript method signatures.
bootstrap() runs this check by default when RunService.IsStudio() is true and skips it in live production servers. { assertContracts } always overrides the environment default; { contracts } overrides the auto-collected registry.
Future direction
Decorated controllers expose runtime controller/route names, but not the argument and return types needed to construct a compile-time-safe remote namespace. They are also server-only while schemas are shared with clients. Inferring complete contracts directly from controller classes would require transformer/code-generation work and a deliberate cross-boundary design, so it remains a follow-up rather than part of the bootstrap sugar API.
Current scope
- Server
@Controllerroute discovery and wiring through Flamework reflection - Per-player, per-route server
@Throttleenforcement before guards and pipes - Plain client
@System()singletons with Flamework DI and lifecycle hooks - Contract-backed
System(contract)gateway methods when paired with@System() - One-time client remote registration through
registerSystemRemotes - Manual
createControllerGatewaywiring for consumers that prefer it
Verification status
The repository playground builds both the framework and consumer workspaces and contains a client checklist for:
- Every Inventory and Status route
- Provider-backed state and constructor DI
- Guard rejection
- Pipe type rejection and exact arity rejection
- Interceptor side effects
- Custom exception filter envelopes
- Exported-provider success and unexported-provider rejection
- Plain
@System()startup, constructor DI, and lifecycle behavior @System()plusextends System(contract)preload, gateway calls, constructor DI, and lifecycle behavior- Missing
registerSystemRemotesbootstrap rejection - Per-player throttle isolation with two connected Studio players
- GET throttle failure envelopes, sliding-window reset, decorator-order independence, and disconnect cleanup
- Controller-level guards, pipes, interceptors, filters, and throttle defaults
- Controller/method composition order, filter precedence, interceptor nesting, and method throttle overrides
The previously documented Remo playground path, including the 0.2 request pipeline, 0.3 client-system cases, and 0.4 per-player route throttling above, has been manually verified in Roblox Studio. The controller-level pipeline additions compile and are covered by the playground harness, but require a fresh Studio run before release. BytNet and Tether have not been Studio-verified.
Known limitations
- Flamework 1.3.2 does not infer decorator metadata from
extends System(contract). Add@System()to each contract-backed subclass; without it, Flamework does not register the subclass, emit constructor dependency metadata, or run lifecycle hooks. - Client-side guard and pipe support does not exist yet; guards and pipes remain server-side features for a separate future phase.
- Throttle state is local to one server; there is no distributed or cross-server rate limiting
- No IP-based limiting, violation callback hooks, or automatic escalation for repeated violations
- No sustained-load or adversarial production evidence
- No adversarial production-scale testing
- Manually declared contracts
- Fire-and-forget mutation routes
- Synchronous guards, pipes, interceptors, handlers, and filters
- Early-stage module export checks over a flat singleton graph
Pin exact versions and treat game-specific validation, distributed limits, and enforcement policy as application responsibilities.
