{"id":"karlobii/bouncer","name":"bouncer","scope":"karlobii","platform":"roblox","description":"Token-bucket rate limiting + validation wrapper for RemoteEvents and RemoteFunctions. No framework required","version":"1.0.2","latest":"1.0.2","versions":["1.0.0","1.0.1","1.0.2"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"6c91112825f98fdb40b85954df9ae51601cd2c4353a6ee97ca96f3a79cb303c6","likes":0,"downloads":0,"install":"forest install karlobii/bouncer","url":"https://forest.dev/p/roblox/karlobii/bouncer","files":"https://api.forest.dev/ai/package/roblox/karlobii/bouncer/files","readme":"# Bouncer\n\nA small module that wraps a `RemoteEvent` or `RemoteFunction` with:\n\n- **Per-player, per-remote token-bucket rate limiting** (burst tolerant, not a blunt fixed window)\n- **Optional argument validation** - a single predicate function, or an array of per-argument checkers that's drop-in compatible with [`t`](https://wally.run/package/osyrisrblx/t) checkers\n- **Violation reporting**, not silent drops or auto-kicks - you decide what happens when a call looks bad\n- **Optional violation escalation** - fire a callback once a player crosses N violations in a sliding window, for feeding into your own flag/ban system\n\nIt doesn't replace Knit, Comm, or `rbx-net`, it wraps whatever `RemoteEvent`/`RemoteFunction` you're already using, framework or not.\n\n## Install\n\n```toml\n[dependencies]\nBouncer = \"karlobii/bouncer@^1\"\n```\n\n## Usage - RemoteEvent\n\n```lua\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\nlocal Bouncer = require(ReplicatedStorage.Packages.Bouncer)\nlocal t = require(ReplicatedStorage.Packages.t)\n\nlocal giveItemRemote = ReplicatedStorage.GiveItemRemote\n\nlocal guard = Bouncer.new(giveItemRemote, {\n\trate = { max = 5, window = 1 }, -- 5 tokens refill per second\n\tburst = 10,                     -- bucket can hold up to 10\n\tvalidate = { t.string, t.number }, -- per-argument, t-compatible checkers\n\tonViolation = function(player, reason, detail)\n\t\twarn((\"[Bouncer] %s violated %s (%s)\"):format(player.Name, reason, detail or \"n/a\"))\n\t\t-- log, soft-flag, escalate - Bouncer never kicks for you\n\tend,\n\tescalation = {\n\t\tthreshold = 5, -- 5 violations...\n\t\twindow = 10,   -- ...within 10 seconds...\n\t\tonEscalate = function(player, count)\n\t\t\twarn((\"[Bouncer] %s escalated: %d violations\"):format(player.Name, count))\n\t\t\t-- e.g. player:Kick(...), flag in a moderation datastore, etc.\n\t\tend,\n\t},\n})\n\nguard:Connect(function(player, itemId, qty)\n\t-- Only reached for calls that passed rate limiting AND validation.\n\tgiveItem(player, itemId, qty)\nend)\n```\n\nBouncer forwards whatever arguments the client fires - `remote:FireServer(itemId, qty)` on the client arrives as `guard:Connect(function(player, itemId, qty) ... end)` on the server.\n\n### Validators\n\n`validate` accepts two shapes:\n\n- **A function** `(...) -> (boolean, string?)` run against the whole argument list - full control, e.g. cross-argument checks (`qty <= inventory.maxStack`).\n- **An array of per-argument checkers**, each `(value) -> (boolean, string?)` - this is exactly the signature `t` checkers already have (`t.string`, `t.number`, `t.interface({...})`, etc.), so you can drop `t` checkers straight in positionally: `validate = { t.string, t.integer, t.optional(t.string) }`. Extra args beyond the checker list pass through unchecked; missing args are checked against `nil`.\n\n## Usage - RemoteFunction\n\n```lua\nlocal guard = Bouncer.new(myRemoteFunction, {\n\trate = { max = 5, window = 1 },\n\tvalidate = { t.string },\n\trejectionResponse = { false, \"rejected\" }, -- what InvokeServer's caller receives on rejection\n})\n\nguard:OnInvoke(function(player, itemId)\n\treturn true, computeSomething(itemId)\nend)\n```\n\n`RemoteFunction.OnServerInvoke` only supports one handler at a time, so `:OnInvoke` sets it directly (same restriction Roblox itself imposes - don't call `:OnInvoke` twice on the same guard). Rejected calls never reach your handler and instead return `rejectionResponse` (default: no values) to the caller, so client code that does `local ok, data = remote:InvokeServer(...)` can check `ok` either way.\n\n## API\n\n### `Bouncer.new(remote: RemoteEvent | RemoteFunction, config: Config?) -> Bouncer`\n\n| config field | type | default | description |\n|---|---|---|---|\n| `rate` | `{ max: number, window: number }` | `{ max = 10, window = 1 }` | tokens added per `window` seconds |\n| `burst` | `number` | `rate.max` | bucket capacity |\n| `validate` | `(...) -> (boolean, string?)` \\| `{ (value) -> (boolean, string?) }` | none | function or per-argument (`t`-compatible) checker array |\n| `onViolation` | `(player, reason, detail?) -> ()` | none | called on rejected calls only |\n| `escalation` | `{ threshold, window, onEscalate }` | none | fires `onEscalate(player, count)` once `threshold` violations happen within `window` seconds; history resets after firing |\n| `rejectionResponse` | `{ any }` | none (→ `nil`) | RemoteFunction only: values returned to the caller on rejection |\n| `sweepInterval` | `number` | `60` | seconds between stale-state sweeps |\n| `staleAfter` | `number` | `300` | idle seconds before a player's bucket/violation history is dropped |\n\n`reason` is one of `\"RateLimited\" | \"InvalidPayload\" | \"NotAPlayer\"`.\n\n### `guard:Connect(handler: (player, ...) -> ()) -> RBXScriptConnection` - RemoteEvent only\n### `guard:OnInvoke(handler: (player, ...) -> ...any)` - RemoteFunction only\n### `guard:GetRemainingTokens(player) -> number`\n### `guard:ResetPlayer(player)` - clears both rate-limit and violation history\n### `guard:Destroy()`\n\n## Design notes\n\n- **Token bucket, not fixed window** - tolerates normal bursts (e.g. a UI double-click) without penalizing laggy-but-legitimate clients.\n- **Never auto-kicks** - aggressive kicking causes false positives on lag/legit bursts; that decision is left to your `onViolation`/`escalation.onEscalate` callbacks.\n- **Per-player AND per-remote** - one abused remote can't starve traffic on your other remotes.\n- **Framework-agnostic** - wraps a raw `RemoteEvent`/`RemoteFunction`; use it standalone, inside Knit, or around Comm/Net.\n- **`t`-compatible by construction, not by dependency** - Bouncer doesn't depend on `t` itself; any function matching `(value) -> (boolean, string?)` works, `t` checkers just happen to already match that shape.\n\n## Github pages documentation coming soon.","readmeTruncated":false}