Sides (Client / Server / Shared)
Nebra can split your type universe by execution side - the multiplayer sandbox pattern made famous by Garry's Mod, FiveM, and nanos-world. A symbol declared as server-only can't be referenced from a client-side file, and vice versa. Errors are caught at compile time before they reach the runtime.
Sides are opt-in per project. Without a
[sides]block innebra.toml, every file accepts every symbol - exactly the pre-sides default.
Mental model
Two pieces fit together:
- Annotations on declarations - each
declare class,declare function,class,interface, etc. can carry@side(client),@side(server), or@side(shared). The annotation says where the symbol is available. - Globs in
nebra.toml- a[sides]block maps folder globs to the side bits a file is allowed to reach. A file insrc/Server/**typically accepts["server", "shared"].
A reference type-checks if every bit in the symbol's side mask is contained in the file's accepted mask. Unannotated symbols carry an implicit "any" side and are reachable from any file (so existing code keeps working).
Annotating declarations
@side works on top-level decls and on individual class / interface
members - methods, fields, constructors, getters/setters, operators. Both
regular and declare forms are supported.
declare class Player
Name: string
@side(server) function Kick(reason: string): nil
@side(server) function Ban(reason: string): nil
@side(client) function ShowToast(msg: string): nil
@side(shared) function GetID(): number
-- Unannotated members default to "any" - reachable from every file.
function GetName(): string
end
@side(client)
declare class Camera
@side(client) function SetFov(fov: number): nil
end
@side(server) declare function BroadcastChat(msg: string): nil
@side(client) declare function ShowToast(msg: string): nil
The Player example above shows the typical pattern: the class type is
reachable from any side (the user can hold a Player reference everywhere),
but individual methods carry their own side mask. A client file that calls
player:Kick(...) gets a compile error; calling player:ShowToast(...)
works.
You can list multiple sides if a symbol is genuinely available on more than
one (@side(client, server)). Writing @side(client, server, shared) is the
same as not annotating at all - it means "reachable from anywhere".
Configuring file scopes
# nebra.toml
[sides]
"src/Client/**" = ["client", "shared"]
"src/Server/**" = ["server", "shared"]
"src/Shared/**" = ["shared"]
Each key is a glob (supports *, **, ?) matched against the source path
relative to the project root. Each value is the list of side bits the file is
allowed to use.
- A server file (
src/Server/**) acceptsserverandsharedsymbols. - A client file (
src/Client/**) acceptsclientandsharedsymbols. - A shared file (
src/Shared/**) accepts onlysharedsymbols - code in here will run on both sides, so it must not depend on anything that's only available on one of them.
Files that don't match any glob default to "any" - unrestricted. Build
incrementally: start with one folder under [sides], expand from there.
What gets checked
Two compiler passes split the work:
CheckSidesruns right after type-ref resolution. It walks every resolved top-levelNameRefin the file's body - variable uses, function calls, type annotations,new T(...),instanceof,extends/implementsclauses - and validates the symbol's@sidemask against the file's accepted mask.CheckMemberSidesruns after type inference (when receiver types are known) and walks everyDotAccessExpr/MethodCallExpr/NewExpr. It resolves the receiver's class or interface type, locates the member, and checks the member's@side. Member side annotations inherit through the base class / interface chain - annotating a method on the base class covers all child classes that don't shadow it.
When a mismatch is found:
Semantic#ErrSymbolWrongSide:
Symbol 'Player.Kick' is server-side only and cannot be used in
this client+shared-side file
Errors stay normal compile errors, so your editor underlines the offending reference and your CI fails the build.
Notes & limits
@sideworks on whole declarations and on individual class / interface members (methods, fields, constructors, getters/setters, operators). A class type without@sideon the type itself but with@sideon its methods is the typical pattern for shared types with side-scoped behaviour.- A child class that re-declares a parent member without an explicit
@sideis treated as unrestricted - the override intentionally drops the parent's restriction. Annotate the override too if you want the same scoping. @sideis a builtin compiler annotation - it does not go through the user-script annotation pipeline, so you cannot redefine or rewrite it from a.nebannotation file.- Imported symbols inherit the side of the original declaration; you don't need to re-annotate at the import site.
- Type parameters (
<T>) are never side-restricted - the side check skips symbols of kindTypeParam. - The default for unannotated symbols is "any" (wildcard), so introducing
[sides]to an existing project will not silently restrict anything until you start adding@sideannotations.