Skip to content

Kenshiin13/bonsai

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Bonsai

Bonsai

A minimal FiveM framework. The core handles player sessions and nothing else. Everything else is an optional addon you opt into.

Why

XKCD 927 — How Standards Proliferate

Every existing FiveM framework ships a fixed stack: inventory, jobs, money, vehicles, society, characters. And the pieces assume each other exists, so anything that diverges from the assumed shape means fighting the framework.

Bonsai inverts that. On connect, insert the player's license into a row. On subsequent joins, load it. That is the entire core. Identity, characters, money, items, permissions, jobs are all addons that live under bonsai/addons/<name>/ and own their schema, dependencies, and lifecycle.

The whole framework runs as one FiveM resource. You start bonsai and the addons listed in config.lua boot in dependency order. No coordinating five resources in server.cfg, no version drift between framework parts, no cross-resource event soup.

Dependencies that actually mean something

Bonsai boot log — optional dep warning + clean migrations

Every addon declares its Dependencies and OptionalDependencies with real semver ranges in its bonsai.lua manifest. The resolver enforces them on boot:

  • Hard dep missing or version mismatch → the addon is dropped with a structured error and the message tells you exactly what's wrong (required dependency "bonsai" version 0.0.5 found, need >=0.1.0).
  • Optional dep missing → silent. It's optional.
  • Optional dep present but version mismatch → a WARN line names the addon, the version it has, the range it expected, and reminds you that features depending on it may not be available. The addon still boots.
  • Optional dep present and version satisfiesaddon:isLoaded("name") returns true and the feature lights up.

The screenshot above is a real boot: currency declares permissions = "^0.1.0" as an optional dep, the installed permissions is at 0.0.1 (mismatch), so currency boots fine but skips its admin commands and the log flags it. Three addons end up healthy, one warning, zero crashes.

Design rules

  • Minimal core. The player row is (license, first_seen, last_seen). Nothing else.
  • First-class addons. Each addon has a manifest, semver dependencies, Lua migrations, and lifecycle hooks. The resolver runs a topological sort and refuses to start on cycles or missing dependencies.
  • One resource. Addons are subdirectories of bonsai/addons/<name>/, loaded by the core's own orchestrator. No ensure ordering.
  • 100% OOP. ox_lib is a strict dependency. lib.class for every class, ox_lib's require for every module.
  • Strict style. See STYLE_GUIDE.md. Fully typed with LuaLS annotations.

Dependencies

  • ox_lib
  • oxmysql
  • MariaDB 11.7.2 (the version this is built and tested against)

Quick start

  1. Clone bonsai into your resources/ folder.
  2. Make sure ox_lib and oxmysql start before bonsai.
  3. Add ensure bonsai to your server.cfg.
  4. Boot the server. The core creates bonsai_players and bonsai_migrations on first start via its own migration.
  5. Drop an addon folder into bonsai/addons/<name>/ and list its folder name in bonsai/config.lua to enable it.

Layout

bonsai/
├── fxmanifest.lua
├── bonsai.lua                  core's own addon manifest
├── config.lua                  list of enabled addons
├── shared/                     namespace, classes, semver
├── server/                     orchestrator, lifecycle, save scheduler
├── client/                     client boot + Player class
└── addons/
    └── <addonName>/
        ├── bonsai.lua          addon manifest
        ├── server/main.lua
        └── client/main.lua

The core is itself an addon

The framework eats its own dogfood. bonsai/bonsai.lua is the core's own manifest, structurally identical to any addon's manifest. The same scanner that loads addons/<name>/bonsai.lua also loads the core's manifest. The core ends up in bonsai.addons registered under the name bonsai, with its own migration (the one that creates bonsai_players), its own emitter (where lifecycle events fire), and a slot in the dependency graph.

Practical consequences:

  • Addons can declare core = ">=0.1.0" as a dependency and the semver check works against the core's manifest version.
  • Lifecycle events (server:playerLoaded, etc.) are subscribed to via bonsai.addons:get("bonsai"):on(...), the same shape addons use for their own events.
  • Adding a new core-owned table is a migration on the core addon, not a special case.

Boot sequence (server)

  1. MySQL.ready fires.
  2. migrationRunner.bootstrap() ensures bonsai_migrations exists.
  3. The manifest scanner reads config.lua and loads each addon's bonsai.lua from addons/<name>/bonsai.lua, plus the core's own manifest from bonsai/bonsai.lua (the core is treated as an addon called bonsai).
  4. The core registers its own migration (the one that creates bonsai_players).
  5. The dependency resolver runs Kahn's algorithm. Cycles, missing deps, and semver mismatches drop the offending addons plus their transitive dependents, with structured errors logged.
  6. The migration runner applies pending migrations per addon, one transaction each. If a dependency fails, its dependents are pre-failed and skipped.
  7. Each addon's entry scripts (sharedEntry, serverEntry) load in dependency order. The current-booting pointer is set around each load so BonsaiPlayer:registerMixin attributes correctly.
  8. Every addon's addon:boot(fn) callbacks run.
  9. bonsai.isReady = true. Lifecycle handlers register, the autosave loop starts, the client bridge registers.
  10. Already-connected players (after a restart) are reconciled.

Boot sequence (client)

  1. onClientResourceStart("bonsai") fires.
  2. The server pushes a one-shot bonsai:client:bootstrap net event after server:playerLoaded runs. The payload carries the addon manifest list, the local player's license, and every addon's onClientSync snapshot.
  3. Each addon's clientEntry loads, with the current-booting pointer set per addon.
  4. Boot callbacks run.
  5. The LocalBonsaiPlayer is constructed and exposed as bonsai.localPlayer.
  6. bonsai.isReady = true, client:playerLoaded fires on the core addon's emitter.

Player class layout

Three classes, one shared base:

Class Side Notes
BaseBonsaiPlayer shared Identity (source, license), private._MixinData, the registerMixin mechanism.
BonsaiPlayer server Extends Base. Adds identifiers, getIdentifier, emitClient, kick, save, markDirty. Stored in bonsai.players (per-source + per-license registry).
LocalBonsaiPlayer client Extends Base. Represents the local player. Exposed as bonsai.localPlayer (there's only one).

The asymmetry is intentional: the server has many players, the client has exactly one. The class hierarchy reflects that.

Extending the player

Addons extend the player surface with mixins. Server side:

local addon = bonsai.addons:require("currency")

bonsai.BonsaiPlayer:registerMixin({
    ---@param self BonsaiPlayer
    ---@param name string
    ---@return number
    getBalance = function(self, name)
        return readBalance(self, name)
    end,
})

Client side uses bonsai.LocalBonsaiPlayer:

---@class LocalBonsaiPlayer
---@field getBalance fun(self: LocalBonsaiPlayer, name: string): number
bonsai.LocalBonsaiPlayer:registerMixin({
    ---@param self LocalBonsaiPlayer
    ---@param name string
    ---@return number
    getBalance = function(self, name)
        local state = self.private._MixinData.currency
        if (not state) then
            return 0
        end
        return state.Balances[name] or 0
    end,
})

Names follow the same camelCase verb-noun rule as any other method. Collisions across addons are a hard error at boot.

Mixin methods are added at runtime, so LuaLS can't see them by default. Every addon that registers mixins extends the relevant class inline with a ---@class block (and ---@field rows for each new method) immediately above the registerMixin call. The annotations live next to the implementation that satisfies them, so renaming a method or changing its signature surfaces a diagnostic on the spot. See bonsai/addons/currency/server/main.lua and bonsai/addons/currency/client/main.lua for the pattern.

Lifecycle events

The core is itself registered as an addon called bonsai, so lifecycle events live on its emitter. Subscribe by name:

bonsai.addons:get("bonsai"):on("server:playerLoaded", function(player)
    bonsai.logger.info("player loaded", { license = player:getLicense() })
end)

Event names on an addon's emitter are <context>:<camelCaseName>. The addon name is implicit because the emitter is scoped. (FiveM net events like RegisterNetEvent use the fully-qualified <addonName>:<context>:<name> form because they actually cross resource boundaries.)

Event Payload
server:playerConnecting (source, name, deferrals)
server:playerLoading (source, license)
server:playerLoaded (player)
server:playerSaving (player)
server:playerDropped (player, reason)
client:playerLoaded (player)

Persistence

Addons register save handlers per namespace. The currency addon does this:

addon:onSave("currency", function(player, tx)
    local State = getState(player)
    for name in pairs(State.Dirty) do
        tx:prepare(
            "REPLACE INTO currency_balances (license, currency_id, balance) VALUES (?, ?, ?)",
            { player:getLicense(), name, State.Balances[name] or 0 })
    end
    State.Dirty = {}
end)

Setters in the addon call player:markDirty("currency") whenever a value changes. The save scheduler batches every dirty namespace for one player into a single MySQL.transaction.await on the autosave tick (60 s by default) and on disconnect. One round-trip per player per tick, regardless of how many addons wrote.

Initial client sync

After server:playerLoaded runs, the core fires a single bonsai:client:bootstrap net event at the player. Addons that need to push per-player state to the client at join time register a sync provider:

addon:onClientSync(function(player)
    return snapshotForClient(player)   -- any serialisable value
end)

The bridge iterates every addon's provider, bundles the results into payload.AddonData, and the client init dispatches each entry as client:initialSync on the corresponding addon's emitter:

addon:on("client:initialSync", function(snapshot, localPlayer)
    -- hydrate local state from snapshot
end)

One round-trip on connect, regardless of how many addons need initial state. Addons that don't need a sync just don't register a provider.

First-party addons

permissions

Group-based player permissions. Define the allowed groups in bonsai/addons/permissions/config/groups.lua:

return {
    defaultGroup = "user",
    Groups = { "admin", "moderator" },
}

defaultGroup is implicit on every player — never stored in the DB, can't be added or removed, and hasPermission("user") always returns true. That makes it the canonical way to mark a permission gate as open-to-everyone.

On boot, any (license, group_name) rows whose group_name is no longer in Groups get wiped — so disabling a group in the config cleanly removes every assignment of it.

API on the player (server and client):

player:hasPermission("admin")                  -- single group
player:hasPermission({ "admin", "moderator" }) -- any-of
player:getGroups()                             -- ["admin", "user"]
player:addGroup("admin")                       -- server only; errors if name not in config
player:removeGroup("admin")                    -- server only

Server emits server:groupsChanged on its own emitter with (player, snapshot). Clients receive permissions:client:groupsChanged automatically and the addon's client:groupsChanged emitter event fires after the local cache updates.

Schema: single sidecar table permissions_groups(license, group_name) with composite PK and FK to bonsai_players.

currency

Configurable currencies on the player. Define any number in bonsai/addons/currency/config/currencies.lua:

return {
    Currencies = {
        { name = "money", label = "Money", symbol = "$", hasDecimal = true,  defaultAmount = 0 },
        { name = "coins", label = "Coins", symbol = "C", hasDecimal = false, defaultAmount = 0 },
    },
    CommandPermissions = {
        AddCurrency    = { "admin" },
        SetCurrency    = { "admin" },
        RemoveCurrency = { "admin" },
    },
}

The permissions addon is an optional dependency. When it's loaded, currency registers the addCurrency, setCurrency, and removeCurrency console/F8 commands; access is gated by CommandPermissions[<key>], which lists the groups allowed to run the command. Console (source = 0) always passes. Putting "user" in a gate list opens the command to every connected player, since user is the implicit default group every player has.

hasDecimal = true stores the amount in cents internally and exposes it as a float with 2 decimal places. defaultAmount > 0 seeds new players (any player without a row for that currency) with the value on next load. Rows are never pre-created on get; an unset currency reads as 0.

API on the player:

player:getBalance("money")              -- 0 if never set
player:setBalance("money", 100.50)
player:addBalance("money", 5.00)
player:removeBalance("money", 2.50)
player:hasBalance("money", 50)          -- bool

Server emits server:balanceChanged on its own emitter with (player, name, oldAmount, newAmount) for any addon that wants to react.

Schema: single sidecar table currency_balances(license, currency_id, balance) with composite PK and FK to bonsai_players. Balance is stored in the currency's smallest unit (cents for decimal currencies, whole units otherwise).

Authoring rules for addons

A few requirements addon authors must respect, beyond the style guide:

  • server:playerLoaded handlers must be idempotent. On a restart bonsai with players still connected, the core reconciles existing players by re-firing server:playerLoaded for each. An addon that grants a sign-on bonus or inserts a row on every event will double-charge. Guard with "already-loaded for this session" state, or use UPSERTs with conflict handling.
  • onClientSync providers should be cheap and side-effect-free. They run inside the bootstrap callback for every connecting player. Reads are fine; writes belong in server:playerLoaded.
  • Save handlers (addon:onSave) should be pure writers. Don't call setters from inside them; the save scheduler clears dirty flags after handlers succeed, and re-entrancy can lose state.
  • Net handlers validate at the boundary (see style guide). The framework provides nothing here; the addon is responsible for type, range, and authorization checks on every parameter from the client.

Configuration

bonsai/config.lua controls enabled addons and a few framework tunables:

return {
    autosaveIntervalMs       = 60000,   -- save scheduler interval
    mysqlReadyTimeoutMs      = 30000,   -- how long to wait for oxmysql before warning
    playerBootstrapTimeoutMs = 5000,    -- max wait for a connecting player to land in the registry

    addons = {
        "currency",
        -- "identity",
        -- ...
    },
}

Status

Pre-release. The core boots cleanly with zero addons enabled. currency is the first first-party addon. Identity, characters, inventory, and permissions are planned.

License

See LICENSE.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages