refactor: move modules to packages (#41)

* refactor: move modules to `packages`

* chore: remove .vscode
This commit is contained in:
richard 2025-01-25 13:12:50 -08:00 • committed by GitHub
parent 89f1dfeaa5
commit 8eddd182d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 152 additions and 174 deletions

View file

@ -0,0 +1,6 @@
{
"name": "CharmSync",
"tree": {
"$path": "src"
}
}

View file

@ -0,0 +1,16 @@
{
"name": "@rbxts/charm-sync",
"version": "0.1.1",
"description": "Sync server state with clients using Charm",
"repository": "littensy/charm",
"license": "MIT",
"main": "src/init.luau",
"types": "src/index.d.ts",
"scripts": {
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
"publish:wally": "wally publish"
},
"dependencies": {
"@rbxts/charm": "workspace:*"
}
}

View file

@ -0,0 +1,82 @@
local Charm = require(script.Parent.Parent.Charm)
local patch = require(script.Parent.patch)
local types = require(script.Parent.types)
type SyncPayload = types.SyncPayload
type ClientOptions = {
--[=[
The atoms to synchronize with the server.
]=]
atoms: { [string | number]: Charm.Atom<any> },
--[=[
Whether to ignore patches sent before the client has been hydrated.
Default is `true`.
]=]
ignoreUnhydrated: boolean?,
}
type ClientSyncer = {
--[=[
Applies a patch or initializes the state of the atoms with the given
payload from the server.
@param ...payloads The patches or hydration payloads to apply.
]=]
sync: (self: ClientSyncer, ...SyncPayload) -> (),
}
--[=[
Creates a `ClientSyncer` object that receives patches from the server and
applies them to the local state.
@param options The atoms to synchronize with the server.
@return A `ClientSyncer` object.
]=]
local function client(options: ClientOptions): ClientSyncer
local self = {} :: ClientSyncer
local atoms = options.atoms
local ignoreUnhydrated = options.ignoreUnhydrated ~= false
local hydrated = false
local function hydrate(state: { [string | number]: any })
hydrated = true
for key, value in next, state do
local atom = atoms[key]
atom(value)
end
end
local function apply(data: { [string | number]: any })
local target = {}
for key, atom in next, atoms do
target[key] = atom()
end
target = patch.apply(target, data)
for key, atom in next, atoms do
atom(target[key])
end
end
function self:sync(...)
for index = 1, select("#", ...) do
local payload: SyncPayload = select(index, ...)
Charm.batch(function()
if payload.type == "init" then
hydrate(payload.data)
elseif not ignoreUnhydrated or hydrated then
apply(payload.data)
end
end)
end
end
return self
end
return client

176
packages/charm-sync/src/index.d.ts vendored Normal file
View file

@ -0,0 +1,176 @@
import { Atom, Selector } from "@rbxts/charm";
export = CharmSync;
export as namespace CharmSync;
type Cleanup = () => void;
declare namespace CharmSync {
type AtomMap = Record<string, Atom<any>>;
type SelectorMap = Record<string, Selector<any>>;
/**
* @deprecated Use `SelectorMap` instead.
*/
type MoleculeMap = SelectorMap;
/**
* Infers the type of the return values produced by a map of functions.
*/
type StateOfMap<T> = {
readonly [P in keyof T]: T[P] extends Selector<infer State> ? State : never;
};
/**
* Represents the removal of a value from the state.
*/
interface None {
readonly __none: "__none";
}
type MaybeNone<T> = undefined extends T ? None : never;
/**
* Creates a `ClientSyncer` object that receives patches from the server and
* applies them to the local state.
*
* @client
* @param options The atoms to synchronize with the server.
* @returns A `ClientSyncer` object.
*/
function client<Atoms extends AtomMap>(options: ClientOptions<Atoms>): ClientSyncer<Atoms>;
/**
* Creates a `ServerSyncer` object that sends patches to the client and
* hydrates the client's state.
*
* @server
* @param options The atoms to synchronize with the client.
* @returns A `ServerSyncer` object.
*/
function server<Selectors extends SelectorMap>(options: ServerOptions<Selectors>): ServerSyncer<Selectors>;
/**
* Checks whether a value is `None`. If `true`, the value is scheduled to be
* removed from the state when the patch is applied.
*
* @param value The value to check.
* @returns `true` if the value is `None`, otherwise `false`.
*/
function isNone(value: unknown): value is None;
type DataTypes = {
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
};
/**
* A type that should not be made partial in patches.
*/
type DataType = DataTypes[keyof DataTypes];
/**
* A partial patch that can be applied to the state to update it. Represents
* the difference between the current state and the next state.
*
* If a value was removed, it is replaced with `None`. This can be checked
* using the `sync.isNone` function.
*/
type SyncPatch<State> =
| MaybeNone<State>
| (State extends ReadonlyMap<infer K, infer V> | Map<infer K, infer V>
? ReadonlyMap<K, SyncPatch<V> | None>
: State extends Set<infer T> | ReadonlySet<infer T>
? ReadonlyMap<T, true | None>
: State extends readonly (infer T)[]
? readonly (SyncPatch<T> | None | undefined)[]
: State extends DataType
? State
: State extends object
? { readonly [P in keyof State]?: SyncPatch<State[P]> }
: State);
/**
* A payload that can be sent from the server to the client to synchronize
* state between the two.
*/
type SyncPayload<Selectors extends SelectorMap> =
| { readonly type: "init"; readonly data: StateOfMap<Selectors> }
| { readonly type: "patch"; readonly data: SyncPatch<StateOfMap<Selectors>> };
interface ClientOptions<Atoms extends AtomMap> {
/**
* The atoms to synchronize with the server.
*/
atoms: Atoms;
/**
* Whether to ignore patches sent before the client has been hydrated.
* Defaults to `true`.
*/
ignoreUnhydrated?: boolean;
}
interface ServerOptions<Selectors extends SelectorMap> {
/**
* The atoms to synchronize with the client.
*/
atoms: Selectors;
/**
* The interval at which to send patches to the client, in seconds.
* Defaults to `0` (patches are sent up to once per frame). Set to a
* negative value to disable automatic syncing.
*/
interval?: number;
/**
* Whether the history of state changes since the client's last update
* should be preserved. This is useful for values that change multiple times
* per frame, where each individual change is important. Defaults to `false`.
*
* If `true`, the broadcaster will send a list of payloads to the client
* instead of a single payload. The client will apply each payload in order
* to reconstruct the state's changes over time.
*/
preserveHistory?: boolean;
/**
* When `true`, convert problematic sparse arrays into dictionaries
* with string keys. Defaults to `true`.
*
* While this is mandatory for safely syncing arrays over vanilla Roblox
* remotes, it can be disabled if your network library uses a custom
* serialization method (i.e. Zap, ByteNet).
*/
serializeArrays?: boolean;
}
interface ClientSyncer<Atoms extends AtomMap> {
/**
* Applies a patch or initializes the state of the atoms with the given
* payload from the server.
*
* @param payloads The patches or hydration payloads to apply.
*/
sync(...payloads: SyncPayload<Atoms>[]): void;
}
interface ServerSyncer<Selectors extends SelectorMap> {
/**
* Sets up a subscription to each atom that schedules a patch to be sent to
* the client whenever the state changes. When a change occurs, the `callback`
* is called with the player and the payloads to send.
*
* Note that a `payload` object should not be mutated. If you need to modify
* a payload, apply the changes to a copy of the object.
*
* @param callback The function to call when the state changes.
* @returns A cleanup function that unsubscribes all listeners.
*/
connect(callback: (player: Player, ...payloads: SyncPayload<Selectors>[]) => void): Cleanup;
/**
* Hydrates the client's state with the server's state. This should be
* called when a player joins the game and requires the server's state.
*
* @param player The player to hydrate.
*/
hydrate(player: Player): void;
}
}

View file

@ -0,0 +1,16 @@
local client = require(script.client)
local patch = require(script.patch)
local server = require(script.server)
local types = require(script.types)
export type SyncPayload = types.SyncPayload
--[=[
Synchronizes state between the client and server. The server sends patches
to the client, which applies them to its local state.
]=]
return {
client = client,
server = server,
isNone = patch.isNone,
}

View file

@ -0,0 +1,30 @@
local RunService = game:GetService("RunService")
--[=[
Schedules a function to be called at a fixed interval of `seconds`.
@param callback The function to call.
@param seconds The interval in seconds.
@return A function that cancels the interval.
]=]
local function interval(callback: () -> (), seconds: number): () -> ()
if seconds < 0 then
return function() end
end
local clock = 0
local connection = RunService.Heartbeat:Connect(function(delta)
clock += delta
if clock >= seconds then
clock = 0
callback()
end
end)
return function()
connection:Disconnect()
end
end
return interval

View file

@ -0,0 +1,121 @@
local validate = require(script.Parent.validate)
local __DEV__ = _G.__DEV__
--[=[
A special symbol that denotes the absence of a value. Used to represent
deleted values in patches.
]=]
local None = { __none = "__none" }
local function isNone(value: any): boolean
return type(value) == "table" and value.__none == "__none"
end
--[=[
JSON serialization can drop the last values of a sparse array. For example,
`{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
serialization of sparse arrays can be resolved by converting them to
dictionaries with string keys.
The `apply` function will convert these dictionaries back into arrays as
long as the target array is not sparse. This function ensures that the diff
function produces a patch that can be correctly applied.
]=]
local function stringifySparseArray(object: { any }): { [string | number]: any }
local maxn = table.maxn(object)
if maxn == 0 or maxn == #object then
-- The object is either not an array or a dense array
return object
end
local dictionary = {}
for index, value in next, object do
dictionary[tostring(index)] = value
end
return dictionary
end
--[=[
Compares two states and returns a patch that can be applied to the previous
state to produce the next state.
@param prevState The previous state.
@param nextState The next state.
@param serializeArrays Whether to convert sparse arrays into dictionaries. Defaults to `true`.
@returns A patch that can be applied to the previous state.
]=]
local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serializeArrays: boolean?)
local patches = table.clone(nextState)
for key, previous in next, prevState do
local current = nextState[key]
if previous == current then
patches[key] = nil
elseif current == nil then
patches[key] = None
elseif type(previous) == "table" and type(current) == "table" then
patches[key] = diff(previous, current, serializeArrays)
end
end
-- Coerce sparse array patches into dictionaries
if serializeArrays ~= false and (prevState[1] ~= nil or nextState[1] ~= nil) then
patches = stringifySparseArray(patches)
end
if __DEV__ then
for key, value in next, prevState do
validate(value, key)
end
for key, value in nextState do
if prevState[key] ~= value then
validate(value, key)
end
end
end
return patches
end
--[=[
Applies a patch to a state and returns the next state.
@param state The current state.
@param patches The patches to apply.
@returns The next state.
]=]
local function apply(state: any, patches: any): any
if type(patches) == "table" and patches.__none == "__none" then
return nil
elseif type(state) ~= "table" or type(patches) ~= "table" then
return patches
end
local nextState = table.clone(state)
local stateIsArray = state[1] ~= nil
for key, patch in next, patches do
-- Diff-checking an array produces a sparse array, which will not be
-- preserved when converted to JSON. To prevent this, we turn string
-- keys back into numeric keys.
if stateIsArray and type(key) == "string" then
key = tonumber(key) or key
end
nextState[key] = apply(nextState[key], patch)
end
return nextState
end
return {
isNone = isNone,
diff = diff,
apply = apply,
}

View file

@ -0,0 +1,262 @@
local Players = game:GetService("Players")
local Charm = require(script.Parent.Parent.Charm)
local interval = require(script.Parent.interval)
local patch = require(script.Parent.patch)
local types = require(script.Parent.types)
type SyncPayload = types.SyncPayload
type ServerOptions = {
--[=[
The atoms to synchronize with the client.
]=]
atoms: { [string | number]: Charm.Atom<any> },
--[=[
The interval at which to send patches to the client, in seconds.
Defaults to `0` (patches are sent up to once per frame). Set to a
negative value to disable automatic syncing.
]=]
interval: number?,
--[=[
Whether the history of state changes since the client's last update
should be preserved. This is useful for values that change multiple times
per frame, where each individual change is important. Defaults to `false`.
]=]
preserveHistory: boolean?,
--[=[
Whether to convert problematic sparse arrays into dictionaries with
string keys. Defaults to `true`.
While this is mandatory for safely syncing arrays over vanilla Roblox
remotes, it can be disabled if your network library uses a custom
serialization method (i.e. Zap, ByteNet).
]=]
serializeArrays: boolean?,
}
type ServerSyncer = {
--[=[
Sets up a subscription to each atom that schedules a patch to be sent to
the client whenever the state changes. When a change occurs, the `callback`
is called with the player and the payload to send.
Note that the `payload` object should not be mutated. If you need to
modify the payload, apply the changes to a copy of the object.
@param callback The function to call when the state changes.
@return A cleanup function that unsubscribes all listeners.
]=]
connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (),
--[=[
Hydrates the client's state with the server's state. This should be
called when a player joins the game and requires the server's state.
@param player The player to hydrate.
]=]
hydrate: (self: ServerSyncer, player: Player) -> (),
--[=[
@deprecated For internal use only.
]=]
_sendPatch: (self: ServerSyncer, player: Player) -> (),
}
--[=[
Creates a `ServerSyncer` object that sends patches to the client and
hydrates the client's state.
@param options The atoms to synchronize with the client.
@return A `ServerSyncer` object.
]=]
local function server(options: ServerOptions): ServerSyncer
local self = {} :: ServerSyncer
local sync: (player: Player, payload: SyncPayload) -> ()
local function createSnapshot()
local snapshot = {}
for key, atom in next, options.atoms do
snapshot[key] = atom()
end
return snapshot
end
-- Send the initial state to a player when they join the server.
function self:hydrate(player)
assert(sync, "connect() must be called before hydrate()")
sync(player, {
type = "init",
data = createSnapshot(),
})
end
-- If the history of state changes should be preserved, keep a list of
-- previous snapshots and diff-check each one from the last.
if options.preserveHistory then
local snapshots = { createSnapshot() }
local changed = false
function self:connect(callback)
local subscriptions = {}
sync = callback
local function pushSnapshot(key: string | number, current: any, previous: any)
local lastSnapshot = snapshots[#snapshots]
local previousSnapshot = snapshots[#snapshots - 1]
-- Optimize snapshots by updating the most recent snapshot if the
-- previous and current values are the same, since this allows us
-- to group multiple changes into a single snapshot.
if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then
lastSnapshot[key] = current
else
local nextSnapshot = table.clone(lastSnapshot)
nextSnapshot[key] = current
table.insert(snapshots, nextSnapshot)
end
end
-- Populate the snapshot with the initial state of each atom.
-- Subscribe to each atom and update the state when it changes.
for key, atom in next, options.atoms do
subscriptions[key] = Charm.subscribe(atom, function(current, previous)
pushSnapshot(key, current, previous)
changed = true
end)
end
local disconnect = interval(function()
if not changed then
return
end
local payloads: { SyncPayload } = {}
local lastSnapshot
for index, snapshot in next, snapshots do
lastSnapshot = snapshot
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, options.serializeArrays),
})
end
snapshots = { lastSnapshot }
changed = false
for _, player in next, Players:GetPlayers() do
callback(player, unpack(payloads))
end
end, options.interval or 0)
return function()
disconnect()
for _, unsubscribe in next, subscriptions do
unsubscribe()
end
end
end
function self:_sendPatch(player: Player)
assert(sync, "connect() must be called before _sendPatch()")
if not changed then
return
end
local payloads: { SyncPayload } = {}
for index, snapshot in next, snapshots do
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, options.serializeArrays),
})
end
snapshots = { snapshots[#snapshots] }
changed = false
sync(player, unpack(payloads))
end
return self
end
local previousSnapshot = createSnapshot()
local currentSnapshot = table.clone(previousSnapshot)
local changed = false
function self:connect(callback)
local subscriptions = {}
sync = callback
-- Subscribe to each atom and update the state when one changes.
for key, atom in next, options.atoms do
subscriptions[key] = Charm.subscribe(atom, function(state)
currentSnapshot[key] = state
changed = true
end)
end
local disconnect = interval(function()
if not changed then
return
end
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, options.serializeArrays),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
for _, player in next, Players:GetPlayers() do
callback(player, payload)
end
end, options.interval or 0)
return function()
disconnect()
for _, unsubscribe in next, subscriptions do
unsubscribe()
end
end
end
function self:_sendPatch(player)
assert(sync, "connect() must be called before _sendPatch()")
if not changed then
return
end
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, options.serializeArrays),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
sync(player, payload)
end
return self
end
return server

View file

@ -0,0 +1,13 @@
--[=[
A payload that can be sent from the server to the client to synchronize
state between the two.
]=]
export type SyncPayload = {
type: "init",
data: { [string | number]: any },
} | {
type: "patch",
data: { [string | number]: any },
}
return nil

View file

@ -0,0 +1,74 @@
local SAFE_KEYS = { string = true, number = true }
local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = true, vector = true }
local function isTableUnsafe(object: { [any]: any })
local keyType = nil
local objectSize = 0
-- All keys must have the same type
for key in next, object do
local currentType = type(key)
if not keyType and SAFE_KEYS[currentType] then
keyType = currentType
elseif keyType ~= currentType then
return true
end
objectSize += 1
end
-- If there are more keys than the length of the array, it's an array with
-- non-sequential keys.
if objectSize > #object and keyType == "number" then
return true
end
return false
end
--[=[
Validates a value to ensure it can be synced over a remote event.
@param value The value to validate.
@param key The key of the value in the table.
@error Throws an error if the value cannot be synced.
]=]
local function validate(value: any, key: any)
local typeOfKey = type(key)
local typeOfValue = type(value)
if not SAFE_KEYS[typeOfKey] then
error(`Invalid key type '{typeOfKey}' at key '{key}'`)
elseif not SAFE_VALUES[typeOfValue] then
error(`Invalid value type '{typeOfValue}' at key '{key}'`)
elseif typeOfValue == "table" then
if getmetatable(value) ~= nil then
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
elseif isTableUnsafe(value) then
error(
`Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n`
.. "This can be for the following reasons:\n"
.. "1. The object is an array with non-sequential keys\n"
.. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n"
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations"
)
end
end
if typeOfValue == "number" then
if value ~= value then
error(`Cannot sync NaN at key '{key}'`)
elseif value == math.huge or value == -math.huge then
error(`Cannot sync infinity at key '{key}'`)
end
elseif typeOfKey == "number" then
if key == math.huge or key == -math.huge then
error("Cannot sync infinity as key")
elseif key ~= math.floor(key) then
error("Cannot sync non-integer number as key")
end
end
end
return validate

View file

@ -0,0 +1,8 @@
[package]
name = "littensy/charm-sync"
version = "0.1.1"
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
[dependencies]
Charm = "littensy/charm@^0.8"