{"id":"natetcz/framework","name":"framework","scope":"natetcz","platform":"roblox","description":"A lightweight typed Service and Controller framework for Roblox","version":"0.1.0","latest":"0.1.0","versions":["0.1.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"941b5d9769def2b55491911cef9265993abe9f15fc6fa97744215c9244c5fbe4","likes":0,"downloads":0,"install":"forest install natetcz/framework","url":"https://forest.dev/p/roblox/natetcz/framework","files":"https://api.forest.dev/ai/package/roblox/natetcz/framework/files","readme":"# Framework\n\nFramework is a small, typed Luau Service/Controller framework for Roblox. It keeps\nthe development shape that makes Knit pleasant while using an independent,\nexplicit networking layer, a deterministic two-phase lifecycle, and safe client\nproxies.\n\nThe server stores ordinary service tables in a direct name lookup. The client\nreceives a new proxy containing only declared `Client` endpoints; the actual\nservice table and its private methods never replicate.\n\n## Installation\n\n### Wally\n\nAfter publishing under your Wally scope, add:\n\n```toml\n[dependencies]\nFramework = \"natetcz/framework@0.1.0\"\n```\n\nThen run `wally install` and map the generated Packages directory in Rojo. The\npackage has no transitive Wally dependencies because its one utility dependency\nis bundled.\n\n### Rojo\n\nCopy this repository or consume it as a Git submodule, then map `src` to a\nModuleScript named `Framework`:\n\n```json\n\"ReplicatedStorage\": {\n  \"Packages\": {\n    \"Framework\": { \"$path\": \"path/to/Framework/src\" }\n  }\n}\n```\n\n`default.project.json` describes the package consumers receive. The complete\nexample development place is mapped by `dev.project.json`.\n\n### Roblox Studio model\n\nImport `dist/Framework.rbxm`, then place the resulting `Framework` ModuleScript\nat `ReplicatedStorage.Packages.Framework`. The model is self-contained: users do\nnot need Rojo, Wally, or a separate Signal package.\n\n## Recommended structure\n\n```text\nReplicatedStorage/Packages/Framework\nServerScriptService/App/Services/*.lua\nServerScriptService/App/init.server.lua\nStarterPlayerScripts/App/Controllers/*.lua\nStarterPlayerScripts/App/init.client.lua\n```\n\n## Services and lifecycle\n\n```lua\nlocal Framework = require(game.ReplicatedStorage.Packages.Framework)\n\nlocal ShopService = Framework.CreateService({\n    Name = \"ShopService\",\n    Client = {\n        PurchaseCompleted = Framework.Signal(),\n        PurchaseItem = Framework.ClientSignal(),\n        GetShopData = Framework.Method(),\n    },\n})\n\nfunction ShopService:Init()\n    self.DataService = Framework.GetService(\"DataService\")\nend\n\nfunction ShopService:Start()\nend\n\nfunction ShopService.Client:GetShopData(player)\n    return { Coins = 100 }\nend\n\nfunction ShopService.Client:PurchaseItem(player, itemId)\n    ShopService:Purchase(player, itemId)\nend\n\nfunction ShopService:Purchase(player, itemId)\n    -- Validate ownership and mutate authoritative server state here.\nend\n\nreturn ShopService\n```\n\nThe descriptor is captured by `CreateService`; defining the endpoint handler\nafterward intentionally replaces that entry in the server's `Client` table.\n\nLoad and start on the server:\n\n```lua\nFramework.AddServices(script.Parent.Services)\nFramework.Start()\n```\n\nRegistration closes as soon as `Start` is called. Services initialize in their\nregistration order. Every yielding `Init` completes before the next one begins,\nand all `Init` calls complete before networking is published or any `Start`\nruns. `Start` is single-use. Recursive module loading is sorted by full Instance\nname for deterministic registration.\n\n## Controllers\n\n```lua\nlocal Framework = require(game.ReplicatedStorage.Packages.Framework)\n\nlocal ShopController = Framework.CreateController({ Name = \"ShopController\" })\n\nfunction ShopController:Init()\n    self.UIController = Framework.GetController(\"UIController\")\n    self.ShopService = Framework.GetService(\"ShopService\")\nend\n\nfunction ShopController:Start()\nend\n\nreturn ShopController\n```\n\nClient bootstrap:\n\n```lua\nFramework.AddControllers(script.Parent.Controllers)\nFramework.Start()\n```\n\nServices communicate with services and controllers with controllers through\ndirect table lookups—no dependency injection or networking is involved.\n\n## Networking\n\n### Server to client signal\n\nDeclare `Updated = Framework.Signal()`.\n\n```lua\n-- Server\nself.Client.Updated:Fire(player, data)\nself.Client.Updated:FireAll(data)\nself.Client.Updated:FireExcept(player, data)\nself.Client.Updated:FireFor(players, data)\n\n-- Client\nShopService.Updated:Connect(function(data) end)\n```\n\n### Client to server signal\n\nDeclare `Submit = Framework.ClientSignal()` and implement\n`function Service.Client:Submit(player, ...)`. Fire it with\n`Service.Submit:Fire(...)` on the client. Roblox supplies `player`; a client\ncannot select or impersonate that argument.\n\n### Client to server method\n\nDeclare `GetData = Framework.Method()` and implement\n`function Service.Client:GetData(player, ...)`. Call it as\n`Service:GetData(...)` on the client. Handler failures are logged with detail on\nthe server while the client receives only a generic request failure, preventing\nserver stack-trace disclosure.\n\nEach endpoint maps to exactly one cached `RemoteEvent` or `RemoteFunction`, made\nonce at startup. Remote type folders are protocol metadata, not a security\nmechanism; explicit declarations, server handlers, and validation are the\nsecurity boundary.\n\n## Validation, rate limiting, and cooldowns\n\nSecurity work is opt-in per client-to-server endpoint:\n\n```lua\nSubmit = Framework.ClientSignal({\n    RateLimit = 5,\n    Window = 1,\n    Cooldown = 0.1,\n    Validate = function(player, itemId, quantity)\n        return type(itemId) == \"string\"\n            and #itemId <= 50\n            and type(quantity) == \"number\"\n            and quantity % 1 == 0\n            and quantity >= 1\n            and quantity <= 10,\n            \"invalid purchase\"\n    end,\n})\n```\n\n`RateLimit` is the maximum accepted calls per `Window` seconds. `Cooldown` is the\nminimum gap between accepted calls. State is held in weak-keyed per-player\ntables, so departed players do not require a cleanup loop. Validation runs in a\nprotected call. Rejected events are warned and dropped; rejected methods return\na generic error.\n\nValidation is a shape/abuse guard, not business authorization. Endpoint code\nmust still check inventory, permissions, prices, distance, ownership, and other\nserver-authoritative rules. Never trust values merely because their Luau types\nlook correct.\n\n## Local signals\n\nThe bundled GoodSignal package is available as `Framework.LocalSignal` for\nin-process events:\n\n```lua\nlocal changed = Framework.LocalSignal.new()\nlocal connection = changed:Connect(function(value) end)\nchanged:Fire(\"value\")\nconnection:Disconnect()\n```\n\nGoodSignal is MIT-licensed; attribution is preserved in\n`THIRD_PARTY_LICENSES.md` and the bundled source. No cleanup or Promise package\nis included because neither is needed by the framework runtime.\n\n## Performance\n\nFramework performs no polling, per-frame work, Heartbeat work, or periodic tree\nscans. Startup recursively scans only folders explicitly passed to `AddServices`\nor `AddControllers`. Server `GetService` and client `GetController` are direct\ntable lookups. Client service proxies are built once and cached.\n\nRuntime overhead exists only when networking is used: Roblox remote dispatch, a\nthin endpoint closure, a protected handler call, and—only when configured—a\nper-player gate lookup plus validation. `FireExcept` and `FireFor` iterate their\ntarget player sets when called. Lifecycle execution is sequential by design,\nwhich removes coroutine/Promise allocation and gives simple completion rules.\n\n## Security model\n\n- The client gets a frozen allow-list proxy, never a server service table.\n- Only `Signal`, `ClientSignal`, and `Method` declarations create remotes.\n- Client-supplied arguments are always placed after Roblox's authentic `Player`.\n- Endpoint handlers and validators are protected; Method internals are sanitized.\n- Remote names are considered public and provide no security.\n- Server code remains responsible for authorization and semantic validation.\n\nRoblox cannot prevent exploiters from discovering or manually firing replicated\nremotes. Framework makes those manual calls go through the same rate, validation,\nand handler path as normal calls.\n\n## Building and verification\n\nInstall the pinned Aftman tools, then run:\n\n```powershell\naftman install\n./scripts/build.ps1\n./scripts/verify.ps1\nrojo serve dev.project.json\n```\n\n`build.ps1` invokes Rojo against `model.project.json` and generates both\n`dist/Framework.rbxm` and the inspectable `dist/Framework.rbxmx` from the same\n`src` source tree. `verify.ps1` runs StyLua and Selene checks, rebuilds both\nmodels, checks the binary is nontrivial, and checks the XML model contains the\nserver, client, endpoint, and bundled Signal modules.\n\nThe examples directory contains `TestService`, `SecondaryService`,\n`TestController`, and `SecondaryController`. It demonstrates both networking\ndirections, a method, direct service/controller access, lifecycle ordering,\nvalidation, rate limiting, cooldowns, and an absent private client method.\n\n## Migration from Knit\n\nThe mapping is intentionally small:\n\n| Knit | Framework |\n| --- | --- |\n| `Knit.CreateService()` | `Framework.CreateService()` |\n| `Knit.CreateController()` | `Framework.CreateController()` |\n| `Knit.GetService()` | `Framework.GetService()` |\n| `Knit.GetController()` | `Framework.GetController()` |\n| `Knit.AddServices()` | `Framework.AddServices()` |\n| `Knit.AddControllers()` | `Framework.AddControllers()` |\n| `:KnitInit()` | `:Init()` |\n| `:KnitStart()` | `:Start()` |\n| `Knit.Start()` | `Framework.Start()` |\n| `Knit.CreateSignal()` | `Framework.Signal()` |\n\n```lua\n-- Knit\nlocal TestService = Knit.CreateService({\n    Name = \"TestService\",\n    Client = { Updated = Knit.CreateSignal() },\n})\nfunction TestService:KnitInit() end\nfunction TestService:KnitStart() end\n\n-- Framework\nlocal TestService = Framework.CreateService({\n    Name = \"TestService\",\n    Client = { Updated = Framework.Signal() },\n})\nfunction TestService:Init() end\nfunction TestService:Start() end\n```\n\nFramework deliberately does not emulate Knit internals, middleware, Promises,\nor arbitrary client-callable functions. Client-to-server APIs must be explicitly\ntyped as `ClientSignal` or `Method`, making exposure visible during review.\n","readmeTruncated":false}