{"id":"realthonik/thread","name":"thread","scope":"realthonik","platform":"roblox","description":"A dependency-aware service framework and networking layer for Roblox","version":"2.0.1","latest":"2.0.1","versions":["2.0.1"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"6bf7431f77fc05ecd0156a8da14dc919c3c85e72ab81656a9c3a505c5f8df2f8","likes":0,"downloads":0,"install":"forest install realthonik/thread","url":"https://forest.dev/p/roblox/realthonik/thread","files":"https://api.forest.dev/ai/package/roblox/realthonik/thread/files","readme":"# Thread\r\n\r\nA lightweight Roblox framework for building scalable games with a clean **Service Architecture**, a **typed Communication System**, and a small **Promise** implementation for startup sequencing.\r\n\r\nBased on the previous model \"Wire v1.1.1. by me\". Thread includes service `Client` tables, Signals and Properties, middleware, dependency ordering, cancellable Promises, validation, generated client types, and hand-written equivalents of common utility modules. The runtime stays dependency-free. Wally and Rokit manifests are included for packaging and development tooling only.\r\n\r\nThis document explains not just *what* each piece does, but *how it works internally* and *when to reach for it* — it's meant to be read top to bottom once, then used as a reference afterward.\r\n\r\n## Table of Contents\r\n\r\n- [Project Anatomy](#project-anatomy)\r\n- [Installation](#installation)\r\n- [Core Concept: Services](#core-concept-services)\r\n- [Bootstrapping: Register + Start](#bootstrapping-register--start)\r\n- [The Client Table: Methods, Signals, Properties](#the-client-table-methods-signals-properties)\r\n- [How Networking Actually Works Under the Hood](#how-networking-actually-works-under-the-hood)\r\n- [Dependencies Between Services](#dependencies-between-services)\r\n- [Middleware](#middleware)\r\n- [Critical Services & Startup Failure](#critical-services--startup-failure)\r\n- [Rate Limiting & Timeouts](#rate-limiting--timeouts)\r\n- [Configuration](#configuration)\r\n- [The Low-Level Channel API](#the-low-level-channel-api)\r\n- [Promise — Complete Reference](#promise--complete-reference)\r\n- [Util Modules — Complete Reference](#util-modules--complete-reference)\r\n- [Server vs. Client Cheat Sheet](#server-vs-client-cheat-sheet)\r\n- [Exported Luau Types](#exported-luau-types)\r\n- [Generation, Formatting, and Testing](#generation-formatting-and-testing)\r\n- [Troubleshooting](#troubleshooting)\r\n- [Full API Reference Tables](#full-api-reference-tables)\r\n\r\n## Project Anatomy\r\n\r\n```\r\nThread/\r\n├── src/                  -- Wally/Rojo package root\r\n│   ├── init.luau        -- service registry + lifecycle + startup sequencing\r\n│   ├── Channel.luau     -- everything networking-related (RemoteEvents/Functions)\r\n│   ├── Promise.luau     -- async primitive used by startup and shutdown\r\n│   ├── Generated/       -- generated metadata, runtime manifest, and typed clients\r\n│   └── Util/\r\n│       ├── Signal.luau      -- fast in-process pub/sub (no Instance overhead)\r\n│       ├── Trove.luau       -- cleanup/janitor helper\r\n│       ├── TableUtil.luau   -- table helper functions\r\n│       ├── Option.luau      -- explicit nil-handling wrapper\r\n│       ├── EnumList.luau    -- custom, comparable enums\r\n│       ├── MathUtil.luau    -- lerp, range remapping, fuzzy equality\r\n│       └── TimerUtil.luau   -- debounce/throttle function wrappers\r\n├── Tests/\r\n│   ├── TestRunner.luau   -- ~30-line assert-based test runner, no TestEZ\r\n│   └── Thread.spec.luau  -- the actual test suite\r\n├── generated/            -- generated service manifest and static client types\r\n├── default.project.json  -- published package model\r\n├── dev.project.json      -- local development/test place\r\n├── integration.project.json\r\n├── thread.config.json    -- source of truth for versions and generated files\r\n├── rokit.toml            -- pinned development toolchain\r\n├── wally.toml            -- generated package manifest\r\n├── README.md\r\n├── CHANGELOG.md\r\n└── LICENSE\r\n```\r\n\r\nThe runtime requires nothing outside `src/`. You can install the Studio-ready release asset, map the source with Rojo, or consume the package through Wally after it is published to the registry.\r\n\r\n## Installation\r\n\r\n### Roblox Studio asset (recommended)\r\n\r\n1. Open the [Thread releases page](https://github.com/realthonik/Thread/releases) and select `v2.0.1`.\r\n2. Download `Thread.v2.0.1.rbxm` from the release assets.\r\n3. Drag the file into Roblox Studio.\r\n4. Move the resulting `Thread` ModuleScript to `ReplicatedStorage.Packages`.\r\n\r\nThen require Thread normally:\r\n\r\n```lua\r\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\r\nlocal Thread = require(ReplicatedStorage.Packages.Thread)\r\n```\r\n\r\nThread has no runtime dependencies of its own.\r\n\r\n### Rojo source mapping\r\n\r\nIf you are consuming a source checkout, map `src` as the `Thread` ModuleScript rather than mapping it directly as `Packages`:\r\n\r\n```json\r\n\"ReplicatedStorage\": {\r\n  \"Packages\": {\r\n    \"$className\": \"Folder\",\r\n    \"Thread\": {\r\n      \"$path\": \"src\"\r\n    }\r\n  }\r\n}\r\n```\r\n\r\n### Wally (available after registry publication)\r\n\r\nThread includes a valid Wally manifest, but the package must exist in the official registry before this command will work. Verify that [`realthonik/thread`](https://wally.run/package/realthonik/thread) lists version `2.0.1`; otherwise use the Studio asset above.\r\n\r\nAdd Thread to your game's `wally.toml`:\r\n\r\n```toml\r\n[dependencies]\r\nThread = \"realthonik/thread@2.0.1\"\r\n```\r\n\r\nInstall dependencies:\r\n\r\n```bash\r\nwally install\r\n```\r\n\r\nMap Wally's `Packages` directory into `ReplicatedStorage.Packages` in your Rojo project:\r\n\r\n```json\r\n{\r\n  \"ReplicatedStorage\": {\r\n    \"Packages\": {\r\n      \"$path\": \"Packages\"\r\n    }\r\n  }\r\n}\r\n```\r\n\r\nThe same `require(ReplicatedStorage.Packages.Thread)` call shown above then works.\r\n\r\n### Manual installation\r\n\r\nIf you are not using Wally, copy the contents of `src/` into a `ModuleScript` named `Thread`, preserving its child hierarchy:\r\n\r\n```\r\nReplicatedStorage\r\n└── Packages\r\n    └── Thread\r\n        ├── Channel\r\n        ├── Promise\r\n        ├── Generated\r\n        └── Util\r\n            ├── Signal\r\n            ├── Trove\r\n            ├── TableUtil\r\n            ├── Option\r\n            ├── EnumList\r\n            ├── MathUtil\r\n            └── TimerUtil\r\n```\r\n\r\nThe package root is `src/init.luau`; its sibling files are children of the resulting `Thread` ModuleScript under Rojo.\r\n\r\nThere is no fixed location for your own game code — `Thread.Register(folder)` just points at whatever folder you keep your services/controllers in. The conventional layout is:\r\n\r\n```\r\nServerScriptService\r\n└── Services\r\n    ├── MoneyService.luau\r\n    └── DataService.luau\r\n\r\nStarterPlayer\r\n└── StarterPlayerScripts\r\n    └── Controllers\r\n        └── HudController.luau\r\n```\r\n\r\n## Core Concept: Services\r\n\r\nA **Service** is a plain Lua table describing one feature of your game. You define it once with `Thread.CreateService({...})`, then attach normal functions to it as its methods.\r\n\r\n```lua\r\n-- ServerScriptService/Services/MoneyService.luau\r\nlocal Thread = require(game:GetService(\"ReplicatedStorage\").Packages.Thread)\r\n\r\nlocal MoneyService = Thread.CreateService({\r\n    Name = \"MoneyService\", -- required, must be unique\r\n})\r\n\r\nlocal balances = {}\r\n\r\nfunction MoneyService:GetMoney(player)\r\n    return balances[player] or 0\r\nend\r\n\r\nfunction MoneyService:GiveMoney(player, amount)\r\n    balances[player] = self:GetMoney(player) + amount\r\nend\r\n\r\nreturn MoneyService\r\n```\r\n\r\nA service can optionally define three lifecycle methods:\r\n\r\n- **`ThreadInit(self)`** — runs first, for every service, in dependency order (see [Dependencies](#dependencies-between-services)). Use this to set up internal state and grab references to other services via `Thread.GetService(...)`. Client remotes are already bound at this point (see below), so it's safe to `:Connect()` your own signals here.\r\n- **`ThreadStart(self)`** — runs after the initialization phase, for services whose initialization and dependencies succeeded. Use this for logic that depends on other healthy services being fully initialized.\r\n- **`ThreadStop(self)`** runs during `Thread.Stop()` in reverse dependency order, so dependents stop before the services they use.\r\n\r\nAll three lifecycle methods may return a `Thread.Promise`. Thread waits for that Promise before moving to the next dependent service. A failed or cancelled lifecycle Promise is handled like a synchronous lifecycle error.\r\n\r\nThe exact same `Thread.CreateService({...})` call works identically on the client — see [Server vs. Client Cheat Sheet](#server-vs-client-cheat-sheet) for the one thing that differs (the `Client` field).\r\n\r\n## Bootstrapping: Register + Start\r\n\r\nTwo calls, once per side (server and client each need their own):\r\n\r\n```lua\r\n-- ServerScriptService/Server.server.lua\r\nlocal Thread = require(game:GetService(\"ReplicatedStorage\").Packages.Thread)\r\n\r\nThread.Register(game:GetService(\"ServerScriptService\").Services)\r\nThread.Start():catch(warn)\r\n```\r\n\r\n```lua\r\n-- StarterPlayerScripts/Client.client.lua\r\nlocal Thread = require(game:GetService(\"ReplicatedStorage\").Packages.Thread)\r\n\r\nThread.Register(script.Parent.Controllers)\r\nThread.Start():catch(warn)\r\n```\r\n\r\n**What `Thread.Register(folder, recursive?)` actually does:** it walks every `ModuleScript` directly inside `folder` (pass `recursive = true` to walk nested subfolders too) and calls `require()` on each one. Since your service files call `Thread.CreateService({...})` at the top level, simply *requiring* the module is what registers it — `Thread.Register` never has to know your service names in advance. It returns a report array so you can inspect what loaded:\r\n\r\n```lua\r\nlocal results = Thread.Register(folder)\r\nfor _, r in ipairs(results) do\r\n    if not r.Success then\r\n        warn(\"Failed to load\", r.Module.Name, \":\", r.Error)\r\n    end\r\nend\r\n```\r\n\r\n**What `Thread.Start()` actually does, step by step:**\r\n\r\n1. Collects every registered service and computes a dependency order (topological sort — see [Dependencies](#dependencies-between-services)).\r\n2. For every service with a `Client` table, calls `Channel.WrapService(...)` to bind it to real Remote instances (server-only — silently skipped/warned on the client, see [Client Table](#the-client-table-methods-signals-properties)).\r\n3. Calls `ThreadInit` on every service, in dependency order.\r\n4. Calls `ThreadStart` on every healthy service, in dependency order, after the initialization phase finishes.\r\n5. Publishes exact service manifests only after startup completes.\r\n6. Resolves the start `Promise` returned by `Thread.OnStart()`.\r\n\r\nIf a service marked `Critical = true` fails at step 3 or 4, steps after it are skipped and the start `Promise` **rejects** instead of resolving (see [Critical Services](#critical-services--startup-failure)).\r\n\r\n`Thread.OnStart()` returns an observer `Promise` for the same startup result and can be called from anywhere, any number of times, from code that isn't the one that called `Thread.Start()`. Cancelling one observer does not cancel framework startup or other observers:\r\n\r\n```lua\r\nThread.OnStart():andThen(function()\r\n    local MoneyService = Thread.GetService(\"MoneyService\") -- safe now, everything is started\r\nend):catch(warn)\r\n```\r\n\r\nShutdown is Promise-based and idempotent:\r\n\r\n```lua\r\nThread.Stop():await()\r\n-- Thread.OnStop() observes the same shutdown result after Stop has begun.\r\n```\r\n\r\nRemember: **the server and the client each run their own separate copy of `Thread`.** The server's `Thread._Register`, `Thread.OnStart()`, etc. are entirely independent Lua state from the client's — they don't communicate with each other automatically. That's what `Channel` (below) is for.\r\n\r\n## The Client Table: Methods, Signals, Properties\r\n\r\nThis is the feature that replaces manually wiring up `RemoteEvent`/`RemoteFunction` instances. A service can declare a `Client` table describing exactly what it exposes to clients:\r\n\r\n```lua\r\nlocal MoneyService = Thread.CreateService({\r\n    Name = \"MoneyService\",\r\n    Client = {\r\n        -- 1) A METHOD: the client calls this and gets a return value back.\r\n        GetMoney = function(self, player)\r\n            return self.Server:GetMoney(player)\r\n        end,\r\n\r\n        -- 2) A SIGNAL: a one-off push event, server -> client.\r\n        MoneyChanged = Thread.CreateSignal(),\r\n\r\n        -- 3) A PROPERTY: a value kept in sync with clients automatically.\r\n        Jackpot = Thread.CreateProperty(0),\r\n    },\r\n})\r\n```\r\n\r\nA few things worth understanding here:\r\n\r\n- Every `Client` method receives `self` (the `Client` table itself — note **not** the outer service) and `player` (automatically injected — the client can't fake this) as its first two arguments, then whatever the client passed.\r\n- `self.Server` is a back-reference to the outer service table, so a `Client` method can call the \"real\" implementation: `self.Server:GetMoney(player)`.\r\n- `Thread.CreateSignal()` / `Thread.CreateProperty(v)` are just **markers**. When `Thread.Start()` calls `Channel.WrapService`, it walks the `Client` table and replaces each marker **in place** with a real Signal/Property object. So by the time `ThreadInit` runs, `self.Client.MoneyChanged` is already a working object, not a marker.\r\n- `Thread.CreateSignal`, `Thread.CreateUnreliableSignal`, and `Thread.CreateProperty` are literally the same functions as `Channel.CreateSignal`/`Channel.CreateUnreliableSignal`/`Channel.CreateProperty` — `Thread` just re-exports them so you don't have to reach into `Thread.Channel` for something you'll use constantly.\r\n\r\n### Which one do I use?\r\n\r\n| Situation | Use |\r\n|---|---|\r\n| Client asks a one-time question and needs an answer (\"how much money do I have?\") | **Method** |\r\n| Server wants to push a one-off event (\"you leveled up\", \"explosion at X\") | **Signal** |\r\n| A value needs to stay in sync and be readable at any time, including for a client who joins late | **Property** |\r\n| A cosmetic, high-frequency event where occasional packet loss is fine (footstep VFX) | **UnreliableSignal** (`Thread.CreateUnreliableSignal()`) |\r\n\r\n### Using a Signal\r\n\r\n```lua\r\n-- Server\r\nfunction MoneyService:GiveMoney(player, amount)\r\n    balances[player] = self:GetMoney(player) + amount\r\n    self.Client.MoneyChanged:Fire(player, balances[player])       -- to one player\r\n    self.Client.MoneyChanged:FireAll(balances[player])              -- to everyone\r\n    self.Client.MoneyChanged:FireExcept(player, balances[player])   -- to everyone but `player`\r\nend\r\n```\r\n\r\n```lua\r\n-- Client\r\nMoneyService.MoneyChanged:Connect(function(newBalance)\r\n    print(\"New balance:\", newBalance)\r\nend)\r\n```\r\n\r\nA Signal built from `Thread.CreateSignal()` can also be fired *from* the client back to the server (`MoneyService.SomeSignal:Fire(...)` on the client) — it's a two-way object, just used one-directionally in the example above. On the server, `:Connect(fn)` receives `(player, ...)`.\r\n\r\n### Using a Property\r\n\r\n```lua\r\n-- Server\r\nself.Client.Jackpot:Set(500)                     -- new value, broadcast to everyone (see override note below)\r\nlocal current = self.Client.Jackpot:Get()        -- read the shared default back\r\n\r\n-- Per-player overrides (e.g. a personalized value only one player sees):\r\nself.Client.Jackpot:SetFor(player, 9999)\r\nself.Client.Jackpot:GetFor(player)               -- returns 9999 for that player, the default for everyone else\r\nself.Client.Jackpot:ClearFor(player)              -- back to the shared default\r\n```\r\n\r\n```lua\r\n-- Client\r\nprint(MoneyService.Jackpot:Get())                -- last known value, synchronously\r\n\r\nMoneyService.Jackpot:Observe(function(value)\r\n    print(\"Jackpot is now\", value)\r\nend)\r\n-- Observe calls your function IMMEDIATELY with the current value, then again\r\n-- on every future change. Always use Observe over a manual :Get() + polling.\r\n```\r\n\r\n`Property:Set(value)` broadcasts to every currently-connected client **except** those with an active `:SetFor` override — so setting the shared default never clobbers a personalized value. A newly-created `Property` fetches its own current value synchronously the first time a client builds it, so late-joining players never see a stale/default value.\r\n\r\nThere's also `Property:Destroy()` (both sides), which disconnects the internal `PlayerRemoving` connection a server-side Property keeps around to clean up per-player overrides when someone leaves. You won't normally call this directly — `Channel.Destroy(serviceName)` tears down the underlying Remotes for you — it's there mainly for advanced ","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/realthonik/roblox/thread/2.0.1/readme"}