{"id":"charliekendle/networking","name":"networking","scope":"charliekendle","platform":"roblox","description":"Production-grade, type-safe networking library for Roblox. Channels, middleware, validation, security, and debugging built in.","version":"1.2.0","latest":"1.2.0","versions":["1.0.0","1.1.0","1.2.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"889c35b5e2e7cb41b778a2fc2ecd319ae85c1bce9bd900b855bb0acfacd1c238","likes":0,"downloads":0,"install":"forest install charliekendle/networking","url":"https://forest.dev/p/roblox/charliekendle/networking","files":"https://api.forest.dev/ai/package/roblox/charliekendle/networking/files","readme":"# Networking\n\nProduction-grade, type-safe networking for Roblox. Channels, middleware, validation, security, and debugging built in — installed with a single Wally dependency.\n\n> **Status: 1.0 — stable.** Full docs in [docs/api-reference.md](docs/api-reference.md), tutorials in [docs/tutorials.md](docs/tutorials.md), migration notes in [docs/migration.md](docs/migration.md). 1.x guarantees no breaking changes to documented APIs or the wire format without a major bump.\n\n## Installation\n\n```toml\n# wally.toml\n[dependencies]\nNetworking = \"charliekendle/networking@1.2.0\"\n```\n\n```lua\nlocal Network = require(ReplicatedStorage.Packages.Networking)\n```\n\n## Quick Start\n\n```lua\n-- Server\nlocal Combat = Network.Channel(\"Combat\")\n\nCombat:Expect(\"Damage\", Network.Validators.numberRange(0, 100))\nCombat:On(\"Damage\", function(player, amount)\n\t-- amount is guaranteed a finite number 0..100; anything else was\n\t-- dropped (and logged) before this handler could run\nend)\n\nCombat:Fire(player, \"Knockback\", direction)\nCombat:FireAll(\"RoundStarted\", roundId)\n```\n\n```lua\n-- Client\nlocal Combat = Network.Channel(\"Combat\")\n\nCombat:Fire(\"Damage\", 25)\n\nCombat:On(\"Knockback\", function(direction)\nend)\n```\n\n### Typed events (1.1)\n\nFor maximum safety and minimum bandwidth, `Define` an event with a Schema — one declaration is simultaneously the **wire format**, the **static type**, and the **validation**:\n\n```lua\nlocal S = Network.Schema\n\n-- shared module, required by server and client\nlocal Attack = Combat:Define(\"Attack\", S.struct({\n\tSeq = S.u16,\n\tPower = S.int(0, 100), -- 1 byte on the wire, range-checked on decode\n\tDirection = S.vec3,\n}))\n\nAttack:On(function(player, value)\n\t-- value : { Seq: number, Power: number, Direction: Vector3 } — statically typed,\n\t-- and a hostile buffer with Power=200 physically cannot decode into here\nend)\n\nAttack:Fire({ Seq = 7, Power = 55, Direction = dir }) -- typed; 15 bytes on the wire\n```\n\nDefined events skip the runtime validator pass entirely (decode enforces the schema by construction) and pack payloads into a single buffer with zero key-name overhead. The untyped `Fire`/`Expect` tier remains for prototyping.\n\nEach channel owns its remotes; event names are scoped per channel (\"Ping\" on Combat and \"Ping\" on Trading never interact). `Network.Channel(name)` is memoized — grab it anywhere, no registry module needed. The same `Fire`/`On` family also exists top-level on `Network`, operating on the built-in `Global` channel.\n\n## Live API (1.2.0)\n\n### Channels\n\n| Member | Context | Description |\n| --- | --- | --- |\n| `Network.Channel(name)` | both | Memoized channel accessor. Server: creates remotes eagerly. Client: discovery defers to first use. |\n| `channel:On/Once/Off` | both | Same semantics as the Global versions below, scoped to the channel. |\n| `channel:Fire/FireUnreliable` | both | Server: `(player, event, ...)`. Client: `(event, ...)`. |\n| `channel:FireAll/FireAllUnreliable/FireExcept/FireList` | server | Broadcast family, scoped to the channel. |\n| `channel:Define(event, codec)` | both | Typed event: schema = wire format = type = validation. Returns `EventDef<T>`. Define in shared code. |\n| `channel:SetRateLimit(limits?)` | server | Per-channel rate-limit override (partial; nil clears). |\n| `channel:Expect(event, ...validators)` | both | Positional schema for incoming packets (untyped tier); failures dropped + logged before handlers. Register server-side for security. |\n| `channel:Use(middleware)` | both | Inbound middleware scoped to the channel. |\n| `channel:OnInvoke(event, handler)` | both | Answer invokes. Server handler `(player, ...) -> ...reply`; client `(...) -> ...reply`. May yield. |\n| `channel:Invoke(event, ...)` | client | Request/response; returns a Promise. `InvokeWithOptions({Timeout, Retries}, ...)` to override defaults. |\n| `channel:InvokeClient(player, event, ...)` | server | Invoke a client; always timeout-bounded, never retried. |\n| `channel:FireNearby(position, radius, event, ...)` | server | Fire to players within `radius` studs of a position. |\n| `channel:FireWithinRadius(player, radius, event, ...)` | server | Fire to players near another player (origin included). |\n\n### Validation\n\n`Network.Expect(event, ...validators)` is the Global-channel equivalent of `channel:Expect`. Rules: extra arguments beyond the schema are always rejected; trailing `optional(...)` validators express optional arguments; the first failure drops the packet with a positional reason in the log. Gate: `Config.ValidationEnabled` (default on).\n\n`Network.Validators`: `any` `boolean` `string` `number`* `numberRaw` `integer` `table` `buffer` `optional` `literal` `union` `array`(+max len) `dict` `interface` `strictInterface` `numberMin` `numberMax` `numberRange`* `integerRange` `stringMaxLength` `match` `enumItem` `instance` `instanceIsA` `vector3`* `vector2`* `cframe`* `color3` `udim2`* `custom`\n\n\\* rejects NaN/±inf — exploiters cannot smuggle non-finite numbers through a validated event.\n\n### Middleware\n\n`Network.Use(fn)` registers a global inbound middleware (every channel); `channel:Use(fn)` scopes one. Order: global chain (registration order), then the channel chain. A middleware receives a `Packet` (`Channel`, `Event`, `Args`, `ArgCount`, `Player?`, `Kind`, `Timestamp`) and returns a verdict:\n\n```lua\nCombat:Use(function(packet)\n\tif packet.Player and not canFight(packet.Player) then\n\t\treturn \"Drop\" -- permission gate: packet never reaches handlers\n\tend\n\treturn \"Continue\"\nend)\n\nCombat:Use(function(packet)\n\tpacket.Args[1] = math.floor(packet.Args[1] :: any)\n\treturn \"Continue\", packet -- transform: replacement flows downstream\nend)\n```\n\nMiddleware that errors (or returns garbage) **drops the packet — fail closed**. A crashed permission check never fails open. Unused middleware costs nothing: the pipeline skips Packet allocation entirely when no chain applies.\n\n### Request/response\n\n```lua\n-- Server\nShop:OnInvoke(\"GetCatalog\", function(player, category)\n\treturn CatalogService:GetItems(category) -- may yield\nend)\n\n-- Client\nShop:Invoke(\"GetCatalog\", \"Weapons\")\n\t:AndThen(function(items) render(items) end)\n\t:Catch(function(reason) warn(reason) end)\n\nlocal ok, items = Shop:Invoke(\"GetCatalog\", \"Weapons\"):Await()\n```\n\nRides reserved `__invoke`/`__reply` protocol frames with correlation IDs (never the engine RemoteFunction, so timeouts and cancellation are clean). Rejections: the server's error string, `\"InvokeTimeout\"`, or `\"ChannelNotFound\"`. Only timeouts retry (an error reply means the handler already ran); server→client invokes are always timeout-bounded so a silent client can't hang server logic. Server handler errors reach the client as a sanitized `\"Invoke handler error\"` — real errors log server-side only. `Expect` schemas apply to invoke arguments. `Network.Promise` is exposed for reuse.\n\n### Serialization\n\n```lua\nShop:Fire(player, \"Snapshot\", Network.Serialize(bigInventoryTable))\n-- receiving side\nlocal inventory = Network.Deserialize(blob)\n```\n\nPacks a value tree into one compact buffer: variable-width ints, varint lengths, **interned strings** (repeated dictionary keys cost ~2 bytes after the first), nested tables, Vector3/CFrame/Color3/UDim2/EnumItems, buffers. Dramatically smaller than generic argument serialization for large payloads. Errors on Instances/functions/cycles; deserialize rejects corrupt input (pcall it for remote-supplied buffers).\n\nFor repetitive payloads (~1 KB+), layer LZW on top: `Network.Compress(blob)` / `Network.Decompress(blob)`. Automatic raw fallback means output never exceeds input + 1 byte on incompressible data.\n\n### Debugging\n\n`Network.GetStats()` — total and per-channel Sent/Received/Dropped counters plus rolling average invoke RTT. `Network.GetPacketLog()` — last 128 packets (direction, channel, event, arg count, player), recorded only while `DebugMode` is on.\n\n### Security\n\nEvery incoming packet runs the server pipeline **rate limit → envelope → schema → middleware → dispatch**; any drop is a strike, a clean packet resets strikes, and `PunishThreshold` consecutive strikes fires the hook. The package never kicks or bans on its own:\n\n```lua\nNetwork.OnSuspiciousActivity:Connect(function(report)\n\t-- report: Player, Channel, Event?, Kind (\"RateLimit\"|\"Validation\"|\"Envelope\"), Reason, Strikes\n\treport.Player:Kick(\"Network abuse detected\")\nend)\n\nlocal entries = Network.GetAuditLog() -- most recent 256 drops, oldest first\n```\n\nRate limiting is a token bucket per player **per channel** (defaults: 60 packets/s + 20 burst), overridable per channel via `channel:SetRateLimit` — Combat at 120/s and Shop at 5/s coexist. A flood on one channel can't starve another. All limits live-tunable; gates: `SecurityEnabled`, `RateLimit.Enabled`.\n\nThree tiers of violation visibility, **all optional** (defaults never require reading any of them): `Network.OnViolation` streams every drop raw; `OnSuspiciousActivity` fires at the consecutive-strike threshold; `GetAuditLog()` keeps the last 256 for forensics. Incoming defined-event buffers are size-capped by `Config.MaxPacketBytes` (default 64 KiB) before any decode work, and `Deserialize` validates claimed element counts against the byte budget and caps nesting depth — hostile buffers cannot trigger large allocations or deep recursion.\n\n### Global channel\n\n| Member | Context | Description |\n| --- | --- | --- |\n| `Network.On(event, cb)` | both | Listen. Server cb: `(player, ...)`. Client cb: `(...)`. Never yields. Returns Connection. |\n| `Network.Once(event, cb)` | both | As On; auto-disconnects after first packet. |\n| `Network.Off(event)` | both | Disconnect all listeners for the event. |\n| `Network.Fire(...)` | both | Reliable send. Server: `(player, event, ...)`. Client: `(event, ...)` — first use of a channel may yield briefly during remote discovery. |\n| `Network.FireUnreliable(...)` | both | Unreliable send (may drop, never resent). Same signatures as Fire. |\n| `Network.FireAll(event, ...)` | server | Reliable broadcast to all players. |\n| `Network.FireAllUnreliable(event, ...)` | server | Unreliable broadcast. |\n| `Network.FireExcept(excluded, event, ...)` | server | Broadcast excluding a Player or `{Player}`. |\n| `Network.FireList(players, event, ...)` | server | Send to an explicit player list. |\n| `Network.Configure(patch)` | both | Deep-merge partial config. Unknown keys and invalid values error immediately. |\n| `Network.GetConfig()` | both | Read-only snapshot of active config. |\n| `Network.ConfigChanged` | both | Signal fired with the new snapshot after every change. |\n| `Network.Signal` | both | The package's strict-typed signal implementation, exposed for reuse. |\n| `Network.Version` | both | SemVer string. |\n\nEvent names: 1–100 chars, `__` prefix reserved for protocol traffic. Incoming packets failing these rules are dropped and logged before they can reach your handlers.\n\n### Configuration\n\n```lua\nNetwork.Configure({\n\tDebugMode = true,\n\tLogLevel = \"Debug\", -- \"Debug\" | \"Info\" | \"Warn\" | \"Error\" | \"None\"\n\tValidationEnabled = true,\n\tSecurityEnabled = true,\n\tDefaultTimeout = 10,\n\tDefaultRetries = 2,\n\tRateLimit = {\n\t\tEnabled = true,\n\t\tMaxPacketsPerSecond = 60,\n\t\tBurstAllowance = 20,\n\t\tPunishThreshold = 5,\n\t},\n})\n```\n\n### Signal\n\n```lua\nlocal Signal = Network.Signal\n\nlocal damaged = Signal.new() :: Network.Signal<Player, number>\n\nlocal connection = damaged:Connect(function(player, amount) end)\ndamaged:Once(function(player, amount) end)\nlocal player, amount = damaged:Wait()\ndamaged:Fire(somePlayer, 25)\nconnection:Disconnect()\n```\n\nZero thread allocation for non-yielding handlers; a handler that errors never breaks other handlers.\n\n## Roadmap\n\n- [x] **Step 1 — Foundation**: tooling, CI, `Types`, `Config`, `Signal`, `Log`, test harness\n- [x] **Step 2 — Core transport**: remote creation/discovery, envelope, `Fire`/`On`/`Once`/`Off`, `FireAll`/`FireExcept`/`FireList`, unreliable variants\n- [x] **Step 3 — Channels**: isolated channel instances with per-channel remotes\n- [x] **Step 4 — Validation**: schema validators (`t`-style), automatic packet rejection\n- [x] **Step 5 — Security**: rate limiting, abuse detection, audit log, suspicious-activity hooks (permissions middleware arrives with Step 6)\n- [x] **Step 6 — Middleware**: global + per-channel chains, transforms, fail-closed error handling\n- [x] **Step 7 — Request/response**: `Invoke`, promises, timeouts, retries, cancellation\n- [x] **Step 8 — Serialization & compression**: value trees → compact buffers (varints, string interning, Roblox datatypes) + LZW with raw fallback\n- [x] **Step 9 — Spatial fire**: `FireNearby`, `FireWithinRadius`\n- [x] **Step 10 — Debugging**: statistics, packet log, invoke RTT tracking\n- [x] **Step 11 — Docs & examples**: full API reference, tutorials, migration guide, benchmarks\n- [x] **1.0.0** — API freeze\n\n## Development\n\nToolchain via [Rokit](https://github.com/rojo-rbx/rokit):\n\n```bash\nrokit install\nrojo sourcemap default.project.json --output sourcemap.json  # analysis\nrojo build test.project.json --output test.rbxl              # test place (F8 units, F5 e2e)\nrojo build benchmark.project.json --output benchmark.rbxl    # benchmark place (F8)\nstylua src tests && selene src tests                          # format + lint\n```\n\nTests live in `tests/` as harness-agnostic spec modules; build the test place and press **Run** (F8) in Studio to execute them. Press **Play** (F5) instead to also run the end-to-end play test in `playtest/` — the client fires 3 Pings, the server replies with Pongs, and the client prints `ALL PONGS RECEIVED` on success.\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n","readmeTruncated":false}