{"id":"ondcerm019/butler","name":"butler","scope":"ondcerm019","platform":"roblox","description":"Fork of Butler from DexxterDax","version":"0.1.1","latest":"0.1.1","versions":["0.1.0","0.1.1"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"141b69f7b9269dd6a6c1074881e77acfb7ef6e4f234cef088528ef0378cb4552","likes":0,"downloads":0,"install":"forest install ondcerm019/butler","url":"https://forest.dev/p/roblox/ondcerm019/butler","files":"https://api.forest.dev/ai/package/roblox/ondcerm019/butler/files","readme":"# Butler v2.0 — Documentation\r\n\r\n> Memory management module for Roblox Luau.  \r\n> Supersedes Maid, Trove, and Janitor with a far richer feature set drawn from systems languages, reactive programming, and modern resource management proposals.\r\n\r\n---\r\n\r\n## Philosophy & Influences\r\n\r\nButler doesn't just copy Maid and Trove — it draws from battle-tested patterns across the software world:\r\n\r\n| Source | Pattern Borrowed | Butler Feature |\r\n|---|---|---|\r\n| **Rust** | `Drop` trait — resources tied to scope lifetime | `butler:Guard()`, LIFO cleanup order |\r\n| **Rust** | `defer!` macro via `scopeguard` crate | `butler:Defer()` |\r\n| **C++** | `std::unique_ptr` with custom deleter, `SCOPE_EXIT` | `butler:Guard()`, `butler:Defer()` |\r\n| **RxJS** | `takeUntil` operator | `butler:Until()` |\r\n| **RxJS** | `CompositeDisposable` / `Subscription` groups | `butler:Batch()` |\r\n| **RxJS** | `finalize()` teardown observer | `butler:OnClean()` |\r\n| **TC39** | Explicit Resource Management (`using` / `Symbol.dispose`) | `butler:Wrap()` |\r\n| **Go** | `defer` statement | `butler:Defer()` |\r\n| **Angular** | `DestroyRef` lifecycle abstraction | `butler:LinkToInstance()` |\r\n\r\n---\r\n\r\n## Why Not Just Use Maid/Trove/Janitor?\r\n\r\n| Problem | Butler Solution |\r\n|---|---|\r\n| No named/keyed task slots — you can't replace one task | `:Set(\"name\", obj)` replaces and cleans the old one |\r\n| `:Destroy()` twice throws an error | Double-destroy is a safe no-op |\r\n| One bad task crashes the whole cleanup chain | All errors are caught, warned, and execution continues |\r\n| No `:Once()` that is also safe if the butler dies first | `:Once()` auto-disconnects OR cleans if butler dies first |\r\n| No sub-scoping / parent-child relationship | `:Scope()` creates a child butler with inherited lifetime |\r\n| Tweens: `:Destroy()` doesn't stop a playing animation | `:Tween()` calls `:Cancel()` before `:Destroy()` |\r\n| No way to replace a running animation/connection atomically | `:Set(\"slot\", newTween)` cancels the old one first |\r\n| No interval helpers — must wire RunService manually | `:Every(interval, fn)` tracked loop, auto-cancelled |\r\n| No thread management — manual `task.spawn` leaks | `:Task()`, `:Delay()` — threads tracked and cancelled |\r\n| No way to couple a resource to its custom teardown inline | `:Guard(value, fn)` — Rust-style Drop at acquisition site |\r\n| No SCOPE_EXIT / defer for side effects | `:Defer(fn)` — Go-style deferred cleanup |\r\n| No batch-add | `:Batch({...})` — add many items at once |\r\n| No teardown hooks | `:OnClean(fn)` — fires after every Clean/Destroy |\r\n| No way to early-dispose one item while keeping tracking | `:Wrap(obj)` returns a disposable proxy |\r\n| No fluent/builder chaining | `:AddChain()` returns self |\r\n| No debug visibility into what's tracked | `:Snapshot()`, `Butler.setDebug(true)` |\r\n\r\n---\r\n\r\n## Installation\r\n\r\nDrop `Butler.rbxm` into **ReplicatedStorage** (or ServerScriptService) and require it:\r\n\r\n```lua\r\nlocal Butler = require(ReplicatedStorage.Butler)\r\n```\r\n\r\n---\r\n\r\n## Quick Start\r\n\r\n```lua\r\nlocal Butler = require(ReplicatedStorage.Butler)\r\nlocal RunService = game:GetService(\"RunService\")\r\n\r\nlocal butler = Butler.new()\r\n\r\n-- Track a connection\r\nbutler:Connect(RunService.Heartbeat, function(dt) doWork(dt) end)\r\n\r\n-- Named slot — auto-replaces on next call\r\nbutler:Set(\"character\", character)\r\n\r\n-- Child scope\r\nlocal charScope = butler:Scope()\r\ncharScope:Add(someModel)\r\n\r\n-- Auto-destroy when player leaves\r\nbutler:LinkToInstance(player)\r\n\r\n-- Done:\r\nbutler:Destroy()\r\n```\r\n\r\n---\r\n\r\n## Full API Reference\r\n\r\n### `Butler.new()` → `Butler`\r\nCreates a new Butler.\r\n\r\n### `Butler.setDebug(enabled: boolean)`\r\nGlobal toggle. When `true`, Butler prints each task as it is cleaned with elapsed time. Use during development to find leaks.\r\n\r\n---\r\n\r\n### Core Tracking\r\n\r\n#### `butler:Add(object, method?, label?)` → `object`\r\nTracks any object for cleanup. Returns the object for inline use.\r\n\r\n| Type | Default Cleanup |\r\n|---|---|\r\n| `function` | `object()` |\r\n| `RBXScriptConnection` | `object:Disconnect()` |\r\n| `thread` | `task.cancel(object)` |\r\n| `Instance` | `object:Destroy()` |\r\n| Table + `Destroy` | `object:Destroy()` |\r\n| Table + `destroy` | `object:destroy()` |\r\n| Table + `Disconnect` | `object:Disconnect()` |\r\n| Tween (has `Cancel`+`Play`) | `object:Cancel()` then `object:Destroy()` |\r\n| Promise (`getStatus`+`cancel`) | `object:cancel()` if Running |\r\n| `method` provided | `object:<method>()` |\r\n\r\n```lua\r\nlocal conn = butler:Add(signal:Connect(fn))\r\nbutler:Add(someInstance)\r\nbutler:Add(myTween, \"Cancel\")\r\nbutler:Add(function() print(\"cleaned!\") end)\r\n```\r\n\r\n#### `butler:AddChain(object, method?)` → `butler`\r\nSame as `:Add()` but returns `self` for builder chaining:\r\n\r\n```lua\r\nButler.new()\r\n    :AddChain(conn1)\r\n    :AddChain(conn2)\r\n    :AddChain(part)\r\n    :LinkToInstance(player)\r\n```\r\n\r\n#### `butler:Set(name, object, method?)` → `object`\r\nNamed slot. If the slot is already occupied, the old object is cleaned first.\r\n\r\n```lua\r\nbutler:Set(\"healthBar\", healthBarGui)\r\n-- Later on respawn:\r\nbutler:Set(\"healthBar\", newHealthBarGui)  -- old one auto-destroyed\r\n```\r\n\r\n#### `butler:Remove(nameOrObject)`\r\nRemove and clean a single task by name or reference:\r\n\r\n```lua\r\nbutler:Remove(\"healthBar\")      -- by name\r\nbutler:Remove(someConnection)   -- by reference\r\n```\r\n\r\n#### `butler:Has(name)` → `boolean`\r\nReturns `true` if a named slot is occupied.\r\n\r\n#### `butler:Count()` → `number`\r\nTotal number of currently tracked tasks.\r\n\r\n#### `butler:IsAlive()` → `boolean`\r\nReturns `false` after `:Destroy()` has been called.\r\n\r\n---\r\n\r\n### Signals\r\n\r\n#### `butler:Connect(signal, fn)` → `RBXScriptConnection`\r\nShorthand for `butler:Add(signal:Connect(fn))`.\r\n\r\n#### `butler:Once(signal, fn)` → `RBXScriptConnection`\r\nOne-shot connection. Disconnects after first fire. If the butler is destroyed first, the connection is still disconnected safely — no dangling callbacks.\r\n\r\n#### `butler:Until(untilSignal, listenSignal, fn)` → `RBXScriptConnection`\r\n**RxJS `takeUntil` pattern.** Connects `fn` to `listenSignal`, but permanently disconnects the moment `untilSignal` fires.\r\n\r\n```lua\r\n-- Update NPC AI on Heartbeat until it dies:\r\nbutler:Until(\r\n    npc.Humanoid.Died,\r\n    RunService.Heartbeat,\r\n    function(dt) updateNPC(npc, dt) end\r\n)\r\n```\r\n\r\n#### `butler:ConnectMany({{signal, fn}, ...})` → `{RBXScriptConnection}`\r\nConnect multiple signal→fn pairs in one call:\r\n\r\n```lua\r\nbutler:ConnectMany({\r\n    { RunService.Heartbeat,   onHeartbeat },\r\n    { player.CharacterAdded, onCharacter  },\r\n})\r\n```\r\n\r\n---\r\n\r\n### Constructing & Instances\r\n\r\n#### `butler:Construct(class, ...)` → `object`\r\nCreate and immediately track an object:\r\n\r\n```lua\r\nlocal sig = butler:Construct(Signal)          -- calls Signal.new()\r\nlocal sig = butler:Construct(Signal.new)      -- function constructor\r\nlocal part = butler:Construct(Instance.new, \"Part\")\r\n```\r\n\r\n#### `butler:Clone(instance)` → `Instance`\r\nClone an instance and track the result.\r\n\r\n#### `butler:AddInstance(inst, parent?)` → `Instance`\r\nTrack an instance and optionally parent it in one call:\r\n\r\n```lua\r\nlocal part = butler:AddInstance(Instance.new(\"Part\"), workspace)\r\n```\r\n\r\n---\r\n\r\n### Async / Threads\r\n\r\n#### `butler:Task(fn)` → `thread`\r\nSpawn a tracked `task.spawn` thread. If the butler is destroyed, the thread is cancelled. Think of it as a Rust *scoped thread* — its lifetime is bounded by the butler's.\r\n\r\n```lua\r\nbutler:Task(function()\r\n    while true do\r\n        doWork()\r\n        task.wait(0.05)\r\n    end\r\nend)\r\n```\r\n\r\n#### `butler:Delay(t, fn)` → `thread`\r\nSchedule a call after `t` seconds. Thread is tracked and cancellable:\r\n\r\n```lua\r\nbutler:Delay(5, function()\r\n    print(\"5 seconds later — or never if butler died\")\r\nend)\r\n```\r\n\r\n#### `butler:Every(interval, fn)` → `thread`\r\nRun `fn` on a fixed interval using a tracked loop. No RunService connection needed:\r\n\r\n```lua\r\nbutler:Every(1/20, function()\r\n    updateMinimap()\r\nend)\r\n```\r\n\r\n---\r\n\r\n### Rust / C++ Patterns\r\n\r\n#### `butler:Guard(value, cleanupFn)` → `value`\r\n**Rust `Drop` trait / C++ `ScopeGuard` / `unique_ptr` with custom deleter.**\r\n\r\nCouples a resource to its cleanup function at the *acquisition site* — the core RAII principle. The cleanup function receives the value as its argument.\r\n\r\n```lua\r\n-- Acquisition and teardown are co-located and explicit:\r\nlocal lock  = butler:Guard(acquireLock(),   function(l) l:release() end)\r\nlocal sound = butler:Guard(Sound:Play(),    function(s) s:Stop() end)\r\nlocal file  = butler:Guard(openFile(path),  function(f) f:close() end)\r\n```\r\n\r\nUnlike `:Add(obj, \"Method\")`, Guard accepts arbitrary teardown logic and makes the relationship between resource and destructor unambiguous.\r\n\r\n#### `butler:Defer(fn)` → `butler`\r\n**C++ `SCOPE_EXIT` / Go `defer` / Rust `defer!` macro.**\r\n\r\nQueues a side-effect to run at the next `Clean()` or `Destroy()`. Explicitly communicates \"this is a cleanup action, not a tracked resource\":\r\n\r\n```lua\r\nbutler:Defer(function()\r\n    Analytics:recordSessionEnd(player)\r\n    print(\"Scope exited.\")\r\nend)\r\n```\r\n\r\nReturns `self` for chaining.\r\n\r\n---\r\n\r\n### RxJS / Reactive Patterns\r\n\r\n#### `butler:Batch({items})` → `butler`\r\n**RxJS `CompositeDisposable` pattern.** Add multiple items at once:\r\n\r\n```lua\r\nbutler:Batch({\r\n    conn1,\r\n    conn2,\r\n    { myTween, \"Cancel\" },  -- {object, method} pair\r\n    { myAudio, \"Stop\"   },\r\n})\r\n```\r\n\r\n#### `butler:OnClean(fn)` → `butler`\r\n**RxJS `finalize()` operator.** Registers a teardown observer that fires after every `Clean()` or `Destroy()`:\r\n\r\n```lua\r\nbutler:OnClean(function()\r\n    Metrics:recordCleanup(player)\r\n    print(\"Cleanup complete!\")\r\nend)\r\n```\r\n\r\nMultiple observers can be registered; all are called in order.\r\n\r\n#### `butler:Wrap(object)` → `DisposableHandle`\r\n**TC39 Explicit Resource Management / `Symbol.dispose` / JavaScript `using` keyword.**\r\n\r\nReturns a proxy that forwards all method calls to the wrapped object, but adds a `:Dispose()` method for early removal from the butler:\r\n\r\n```lua\r\n-- JavaScript equivalent: `using stream = openStream();`\r\nlocal handle = butler:Wrap(openStream())\r\nhandle:Read(1024)   -- proxied to stream:Read(1024)\r\nhandle:Dispose()    -- removes stream from butler, cleans it early\r\n-- If :Dispose() is never called, butler:Destroy() cleans it normally\r\n```\r\n\r\n---\r\n\r\n### Roblox QoL\r\n\r\n#### `butler:Tween(instance, tweenInfo, goals)` → `Tween`\r\nCreate, play, and track a Tween. On cleanup, `:Cancel()` is called *before* `:Destroy()` — stopping any mid-play animation. Maid, Trove, and Janitor all miss this and only call `:Destroy()`, which doesn't stop a playing tween:\r\n\r\n```lua\r\nlocal tween = butler:Tween(\r\n    part,\r\n    TweenInfo.new(0.5, Enum.EasingStyle.Quad),\r\n    { CFrame = targetCFrame }\r\n)\r\n-- part smoothly moves. If butler:Destroy() fires, the part stops immediately.\r\n```\r\n\r\n#### `butler:WaitFor(inst, childName, timeout?)` → `Instance?`\r\nA tracked `WaitForChild` wrapper. If the butler is destroyed while waiting, the coroutine is cancelled — no orphaned yields:\r\n\r\n```lua\r\nlocal gui = butler:WaitFor(player.PlayerGui, \"MainGui\", 10)\r\nif gui then\r\n    setupGui(gui)\r\nend\r\n```\r\n\r\n---\r\n\r\n### Scoping & Linking\r\n\r\n#### `butler:Scope()` → `Butler`\r\nCreate a child butler whose lifetime is tied to the parent. Inspired by Rust's `std::thread::scope`:\r\n\r\n```lua\r\nlocal playerButler = Butler.new()\r\n\r\nlocal function onCharacterAdded(char)\r\n    -- Create a new scope each respawn\r\n    local charScope = playerButler:Scope()\r\n    charScope:Add(char)\r\n    charScope:Once(char.Humanoid.Died, function()\r\n        print(\"died\")\r\n    end)\r\nend\r\n\r\nplayer.CharacterAdded:Connect(onCharacterAdded)\r\n-- When playerButler:Destroy() is called, all charScopes are also destroyed\r\n```\r\n\r\n#### `butler:LinkToInstance(instance, allowReAdd?)` → `RBXScriptConnection`\r\nAuto-destroy the butler when a Roblox instance is destroyed:\r\n\r\n```lua\r\nbutler:LinkToInstance(player)           -- Destroy() on player leave\r\nbutler:LinkToInstance(character, true)  -- Clean() only (reuse the butler)\r\n```\r\n\r\n---\r\n\r\n### Lifecycle\r\n\r\n#### `butler:Clean()`\r\nClean all tasks and fire OnClean observers, but keep the butler alive for reuse. Cleanup order: named tasks → anonymous tasks (LIFO) → OnClean observers.\r\n\r\n#### `butler:Destroy()`\r\nClean and permanently invalidate. Calling `:Destroy()` a second time is a safe no-op.\r\n\r\n#### `butler:Snapshot()` → `{[key]: string}`\r\nReturns a table of all tracked tasks keyed by their index or name. Use during development to inspect what's currently tracked:\r\n\r\n```lua\r\nlocal snap = butler:Snapshot()\r\nfor k, v in pairs(snap) do\r\n    print(k, \"→\", v)\r\nend\r\n-- 1 → RBXScriptConnection\r\n-- 2 → thread\r\n-- healthConn → RBXScriptConnection\r\n-- character → Instance\r\n```\r\n\r\n---\r\n\r\n## Common Patterns\r\n\r\n### Per-Player Setup\r\n\r\n```lua\r\nlocal Butler = require(ReplicatedStorage.Butler)\r\n\r\nlocal playerButlers: { [Player]: typeof(Butler.new()) } = {}\r\n\r\ngame.Players.PlayerAdded:Connect(function(player)\r\n    local butler = Butler.new()\r\n    playerButlers[player] = butler\r\n    butler:LinkToInstance(player)  -- auto-destroys on leave\r\n\r\n    butler:OnClean(function()\r\n        print(player.Name, \"left, all cleaned up\")\r\n    end)\r\n\r\n    butler:Connect(player.CharacterAdded, function(char)\r\n        local scope = butler:Scope()\r\n        scope:Add(char)\r\n        scope:Until(\r\n            char.Humanoid.Died,\r\n            game:GetService(\"RunService\").Heartbeat,\r\n            function(dt) updateCharacter(char, dt) end\r\n        )\r\n    end)\r\nend)\r\n```\r\n\r\n### OOP Class with Butler\r\n\r\n```lua\r\nlocal MySystem = {}\r\nMySystem.__index = MySystem\r\n\r\nfunction MySystem.new(player)\r\n    local self = setmetatable({}, MySystem)\r\n    self._butler = Butler.new()\r\n    self._butler:LinkToInstance(player)\r\n    self._butler:OnClean(function()\r\n        print(\"MySystem cleaned up\")\r\n    end)\r\n    return self\r\nend\r\n\r\nfunction MySystem:Destroy()\r\n    self._butler:Destroy()\r\nend\r\n```\r\n\r\n### Animation State Machine\r\n\r\n```lua\r\n-- Cleanly swap animations using named slots + Tween cleanup\r\nlocal function playAnimation(butler, track, info, goals)\r\n    -- Old tween is Cancel()'d and Destroy()'d before new one plays\r\n    butler:Set(\"activeTween\", butler:Tween(character.HumanoidRootPart, info, goals))\r\nend\r\n```\r\n\r\n### Scoped Debug Session\r\n\r\n```lua\r\nButler.setDebug(true)\r\n\r\nlocal butler = Butler.new()\r\nbutler:Defer(function()\r\n    print(\"Total tasks cleaned:\", taskCount)\r\nend)\r\n\r\nButler.setDebug(false)  -- turn off for production\r\n```\r\n\r\n### Batch Connection\r\n\r\n```lua\r\nbutler:Batch({\r\n    RunService.Heartbeat:Connect(onHeartbeat),\r\n    RunService.RenderStepped:Connect(onRender),\r\n    { myTween, \"Cancel\" },\r\n    { mySound, \"Stop\"   },\r\n})\r\n```\r\n\r\n---\r\n\r\n## Cleanup Order (LIFO — same as Rust)\r\n\r\nButler cleans anonymous tasks in **Last In, First Out** order, mirroring how Rust drops local variables and how C++ destroys stack objects. This means the most recently added resource is cleaned first, which is the safest default:\r\n\r\n```lua\r\nbutler:Add(A)  -- cleaned 3rd\r\nbutler:Add(B)  -- cleaned 2nd\r\nbutler:Add(C)  -- cleaned 1st ← last added, first cleaned\r\n```\r\n\r\nNamed tasks are cleaned before anonymous tasks, in arbitrary order.\r\n\r\n---\r\n\r\n*Butler v2.0.0*\r\n","readmeTruncated":false}