diff --git a/README.md b/README.md index 55c727d..b43797d 100644 --- a/README.md +++ b/README.md @@ -1 +1,148 @@ -# Galaxy \ No newline at end of file +# Galaxy + +A small lightweight framework that lets you grab any module from anywhere with `shared("Name")`. + +## Features + +- Grab any module by name with `shared("Name")`. +- Server and client code is detected and started for you. +- Modules load only when first used, then get cached. + +## Installation + +Add this to your `wally.toml` and run `wally install`: + +```toml +[dependencies] +Galaxy = "artisticvampyre/galaxy@1.2.0" +``` + +## Getting Started + +### 1. Start Galaxy + +From a server `Script` and a client `LocalScript`, tell Galaxy where to look for your code: + +```lua +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Galaxy = require(ReplicatedStorage.Packages.Galaxy) + +Galaxy.Launch({ + -- Where your reusable modules live + Modules = { ReplicatedStorage.Packages, ReplicatedStorage.Shared }, + -- Where your systems live + Systems = { script:WaitForChild("systems") }, +}) +``` + +### 2. Make a system + +A system is just a module whose name ends in `Service` (server) or `Controller` (client). Galaxy runs the right ones automatically and calls `onInit` then `onStart`. + +```lua +-- MainService.luau +local log = shared("log") + +local MainService = {} + +function MainService:onInit() + log.info("MainService initialized") +end + +function MainService:onStart() + log.info("MainService started") +end + +return MainService +``` + +### 3. Use other modules + +Call `shared("Name")` to get any module or system by its name: + +```lua +local MainService = shared("MainService") +local log = shared("log") +``` + +### Tip: autocomplete for `shared(...)` + +Because `shared(...)` takes a string, the editor doesn't know the type by itself. Add a `--@module` annotation above the line so the language server treats it as that module and gives you autocomplete: + +```lua +local MainService = shared("MainService") --@module MainService +``` + +## How It Works + +### Modules and systems + +- **Modules** are any `ModuleScript`. They load the first time you ask for them with `shared(...)`. +- **Systems** are modules named `...Service` or `...Controller`. Galaxy starts them for you when the game launches. + +### Server vs. client + +Galaxy looks at where the code is running and picks the right systems: + +- On the server it runs anything named `Service`. +- On the client it runs anything named `Controller`. + +So you can keep both in the same folder and each side only runs its own. + +### When things start + +When you call `Galaxy.Launch`: + +1. `onInit` is called on every system, one at a time. Use it for setup. +2. `onStart` is called on every system after all setup is done. Use it for your main logic. + +### Loading extra folders into a system + +If a system needs helper modules from nearby folders, list them in `DirectoriesToLoad` and Galaxy attaches them for you: + +```lua +local MyService = { DirectoriesToLoad = { "components", "data" } } + +function MyService:onInit() + -- self.SomeComponent is now available +end + +return MyService +``` + +## When Something Goes Wrong + +- If a module fails to load, Galaxy warns you and keeps going, so one mistake doesn't break everything. +- If a module takes too long to load (over 3 seconds), Galaxy tells you which one. +- If two modules depend on each other in a loop, Galaxy warns you and names them. + +## Reference + +- `Galaxy.Launch({ Modules = {...}, Systems = {...} })` starts the framework. +- `shared("Name")` returns a module or system by name. Works anywhere after `Launch`. +- `Galaxy._VERSION` is the version string. +- On a system, `onInit(self)` runs first for setup, `onStart(self)` runs after for main logic. + +## Project Layout + +``` +src/ + init.luau the framework + CycleMetatable.luau cyclic dependency helper + wally.toml package info +test/ + server/ client/ shared/ example game using Galaxy +``` + +## Development + +Install tools with [Aftman](https://github.com/LPGhatguy/aftman), then run the test place: + +```sh +aftman install +rojo serve test.project.json +``` + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/src/init.luau b/src/init.luau index ed9147f..cc7dcd2 100644 --- a/src/init.luau +++ b/src/init.luau @@ -8,14 +8,18 @@ • ⚡ Optimized for performance • 🛡️ Type-safe with Luau - Version: 1.0.0 + Version: 1.2.0 Author: ArtisticVampyre + Contributor: @32_Bits (https://github.com/git0uttahere) --]] -- 🔧 Core Services -local HttpService: HttpService = game:GetService("HttpService") local RunService: RunService = game:GetService("RunService") +-- ⚙️ Configuration +-- How long (in seconds) a require/onInit may yield before Galaxy warns about it. +local INIT_YIELD_WARNING_TIME = 3 + -- 📝 Type Definitions type LaunchParams = { Modules: { Instance }, @@ -23,7 +27,7 @@ type LaunchParams = { } -- 🌟 Main Module -local Galaxy = { _VERSION = "1.0.0" } +local Galaxy = { _VERSION = "1.2.0" } -- 🎯 Environment Detection local SystemKey: string = RunService:IsServer() and "service" or "controller" @@ -39,6 +43,30 @@ local Systems: { [string]: any } = {} -- Helper Functions ----------------------------------------------------- +local function _errorHandler(err: any): string + return tostring(err) .. "\n" .. debug.traceback() +end + +--[[ + Requires a module with error isolation + a yield watchdog. require() runs the + module's top-level body, which can both error AND yield (e.g. a top-level + WaitForChild / :Wait()). Either would otherwise silently stall the whole boot, + so we catch errors (returning ok = false) and warn by name if the require + yields longer than INIT_YIELD_WARNING_TIME. +]] +local function _safeRequire(module: ModuleScript, label: string): (boolean, any) + local completed = false + task.delay(INIT_YIELD_WARNING_TIME, function() + if not completed then + warn(`⏳[Galaxy][IMPORTANT] requiring {label} {module.Name} has been yielding for {INIT_YIELD_WARNING_TIME}s+. A top-level WaitForChild/:Wait()/yield during require blocks the whole boot, move it into onStart.`) + end + end) + + local ok, result = xpcall(require, _errorHandler, module) + completed = true + return ok, result +end + -- Count the number of ancestors for a given instance. local function countAncestors(instance: Instance): number local count = 0 @@ -74,14 +102,14 @@ end local function _load(module: ModuleScript): any? CycleMetatable.CurrentModuleLoading = module.Name - local success, dataOrErr = pcall(require, module) + local success, dataOrErr = _safeRequire(module, "module") CycleMetatable.CurrentModuleLoading = nil if success then return dataOrErr end - warn(("❌ Failed to load module '%s' 💥\nError: %s"):format(module.Name, dataOrErr)) + warn(`❌ [Galaxy][CRITICAL] Failed to require module {module.Name} (skipped — other modules still load):\n{dataOrErr}`) return nil end @@ -142,7 +170,7 @@ local function _loadInternal(system, rootModule: ModuleScript) for _, dirKey: string in internal do local dir: Folder? = root:FindFirstChild(dirKey) :: Folder? if not dir then - warn(("❗️Internal directory '%s' not found in system '%s'! 🚫"):format(dirKey, root.Name)) + warn(`❗️Internal directory {dirKey} not found in system {root.Name}! 🚫`) continue end @@ -151,13 +179,9 @@ local function _loadInternal(system, rootModule: ModuleScript) continue end - local success, dataOrErr = pcall(require, module) + local success, dataOrErr = _safeRequire(module, "internal module") if not success then - warn("❌ Failed to load internal module '%s' in system '%s'! 💥\nError: %s"):format( - module.Name, - root.Name, - dataOrErr - ) + warn(`❌ [Galaxy] Failed to load internal module {module.Name} in system {root.Name} (skipped)! 💥\nError: {dataOrErr}`) continue end @@ -170,6 +194,10 @@ end local function _indexSystems(location: Instance) for _, descendant: ModuleScript in location:GetDescendants() do if descendant:IsA("ModuleScript") and descendant ~= script then + if descendant:GetAttribute("GalaxyLoaded") then + continue + end + local childIndex = descendant.Name:lower() if childIndex:find(SystemKey) then local activeSystem = _load(descendant) @@ -177,8 +205,7 @@ local function _indexSystems(location: Instance) -- Use the current ModuleScript as the root for internal modules. _loadInternal(activeSystem, descendant) Systems[descendant.Name] = activeSystem - -- Rename the module to avoid collisions. - descendant.Name = HttpService:GenerateGUID() + descendant:SetAttribute("GalaxyLoaded", true) end end end @@ -242,16 +269,27 @@ function Galaxy.Launch(launchParams: LaunchParams) _indexSystems(systemRoot) end - for _, system in Systems do - if system.onInit and typeof(system.onInit) == "function" then - system:onInit() + for name, system in Systems do + if typeof(system.onInit) ~= "function" then + continue + end + + local initThread = task.spawn(function() + system.onInit(system) + end) + + if coroutine.status(initThread) == "suspended" then + warn( + `⏳ [Galaxy][IMPORTANT] {name}.onInit yielded, continuing without waiting for it. Move WaitForChild / :Wait() / network calls into onStart so init order stays deterministic.` + ) end end for _, system in Systems do - if system.onStart and typeof(system.onStart) == "function" then - task.spawn(system.onStart, system) + if typeof(system.onStart) ~= "function" then + continue end + task.spawn(system.onStart, system) end end diff --git a/src/wally.toml b/src/wally.toml index e31558f..f9ea90a 100644 --- a/src/wally.toml +++ b/src/wally.toml @@ -1,6 +1,6 @@ [package] name = "artisticvampyre/galaxy" -version = "1.0.0" +version = "1.2.0" registry = "https://github.com/UpliftGames/wally-index" realm = "shared"