refactor: start charm-sync rewrite

This commit is contained in:
littensy 2025-09-29 18:11:39 -07:00
parent 4a07297a13
commit 079abaacc1
18 changed files with 468 additions and 560 deletions

View file

@ -1,3 +1,7 @@
if not script then
return require("../charm/src")
end
local Charm = require(script.Parent.Parent.charm.src) local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T> export type Atom<T = any> = Charm.Atom<T>

View file

@ -1,19 +1,22 @@
local Charm = require("../../Charm") local Charm = require("../../Charm")
local CharmSync = require("../") local client = require("../client")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local atom = Charm.atom local atom = Charm.atom
local subscribe = Charm.subscribe local subscribe = Charm.subscribe
local client = CharmSync.client
local test = suite.test local test = suite.test
local afterEach = suite.afterEach
afterEach(function()
client.clearAtoms()
end)
test("should sync state payload", function() test("should sync state payload", function()
local a = atom(1) local a = atom(1)
local client = client({ atoms = { a = a } })
client:sync({ client.registerAtoms({ a = a })
type = "init", client.applyPayloads({
data = { a = 2 }, { type = "init", data = { a = 2 } },
}) })
assert(a() == 2, "should set a to the initial value") assert(a() == 2, "should set a to the initial value")
@ -22,18 +25,16 @@ end)
test("should sync state patch", function() test("should sync state patch", function()
local a = atom({ b = 1, c = 2 }) local a = atom({ b = 1, c = 2 })
local previous = a() local previous = a()
local client = client({ atoms = { a = a } })
client:sync({ client.registerAtoms({ a = a })
type = "init", client.applyPayloads({
data = { a = a() }, { type = "init", data = { a = a() } },
}) })
assert(a() == previous, "should set a to the initial value") assert(a() == previous, "should set a to the initial value")
client:sync({ client.applyPayloads({
type = "patch", { type = "patch", data = { a = { b = 3 } } },
data = { a = { b = 3 } },
}) })
assert(a() ~= previous, "should apply patch") assert(a() ~= previous, "should apply patch")
@ -43,19 +44,17 @@ end)
test("should fix sparse array patches", function() test("should fix sparse array patches", function()
local a = atom({ 1 }) local a = atom({ 1 })
local client = client({ atoms = { a = a } })
client:sync({ client.registerAtoms({ a = a })
type = "init", client.applyPayloads({
data = { a = a() }, { type = "init", data = { a = a() } },
}) })
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that -- This patch would be the result of a diff between { 1 } and { 1, 2 } that
-- was JSON-serialized and deserialized. The client should be able to catch -- was JSON-serialized and deserialized. The client should be able to catch
-- this and convert the string key back to a number. -- this and convert the string key back to a number.
client:sync({ client.applyPayloads({
type = "patch", { type = "patch", data = { a = { ["2"] = 2 } } },
data = { a = { ["2"] = 2 } },
}) })
assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers") assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers")
@ -64,24 +63,21 @@ end)
test("should apply multiple patches", function() test("should apply multiple patches", function()
local a = atom(1) local a = atom(1)
local client = client({ atoms = { a = a } })
local calls = {} local calls = {}
client.registerAtoms({ a = a })
subscribe(a, function(value) subscribe(a, function(value)
table.insert(calls, value) table.insert(calls, value)
end) end)
client:sync({ client.applyPayloads({
type = "init", { type = "init", data = { a = a() } },
data = { a = a() },
}) })
client:sync({ client.applyPayloads({
type = "patch", { type = "patch", data = { a = 2 } },
data = { a = 2 }, { type = "patch", data = { a = 3 } },
}, {
type = "patch",
data = { a = 3 },
}) })
assert(a() == 3, "should apply the last patch") assert(a() == 3, "should apply the last patch")
@ -91,9 +87,10 @@ end)
test("should batch patches", function() test("should batch patches", function()
local a = atom(0) local a = atom(0)
local b = atom(0) local b = atom(0)
local client = client({ atoms = { a = a, b = b } })
local states = {} local states = {}
client.registerAtoms({ a = a, b = b })
subscribe(a, function() subscribe(a, function()
states.b = b() states.b = b()
end) end)
@ -102,14 +99,12 @@ test("should batch patches", function()
states.a = a() states.a = a()
end) end)
client:sync({ client.applyPayloads({
type = "init", { type = "init", data = { a = a(), b = b() } },
data = { a = a(), b = b() },
}) })
client:sync({ client.applyPayloads({
type = "patch", { type = "patch", data = { a = 1, b = 2 } },
data = { a = 1, b = 2 },
}) })
assert(a() == 1, "should set a to the new value") assert(a() == 1, "should set a to the new value")

View file

@ -1,9 +1,8 @@
local Charm = require("../../Charm") local Charm = require("../../Charm")
local CharmSync = require("../") local flatten = require("../flatten")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local atom = Charm.atom local atom = Charm.atom
local flatten = CharmSync.flatten
local test = suite.test local test = suite.test
test("should flatten atoms", function() test("should flatten atoms", function()

View file

@ -1,15 +1,33 @@
local HttpService = game:GetService("HttpService") local config = require("../config")
local patch = require("../patch")
local CharmSync = require("../")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local patch = CharmSync.patch
local test = suite.test local test = suite.test
local afterEach = suite.afterEach
local function serdes(value: { [any]: any }) local function mockArraySerdes(object: { [any]: any }): { [any]: any }
return HttpService:JSONDecode(HttpService:JSONEncode(value)) if object[1] then
local copy = {}
for index = 1, #object do
copy[index] = object[index]
end
return copy
elseif table.maxn(object) ~= #object then
local copy = {}
for key, value in object do
copy[tostring(key)] = value
end
return copy
else
return object
end
end end
afterEach(function()
config.autoSerialize = true
config.validatePatches = true
end)
test("returns a patch", function() test("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" } local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" } local nextState = { foo = "bar", baz = "quux" }
@ -60,7 +78,7 @@ test("preserves array density after serdes", function()
local prevState = { "a", "b", "c", "d", "e" } local prevState = { "a", "b", "c", "d", "e" }
local nextState = { "A", "b", "c", "D" } local nextState = { "A", "b", "c", "D" }
local patches = serdes(patch.diff(prevState, nextState)) local patches = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches) local applied = patch.apply(prevState, patches)
for index in next, prevState do for index in next, prevState do
@ -72,27 +90,11 @@ test("preserves array density after serdes", function()
end end
end) end)
test("preserves large gaps between keys", function()
local prevState = table.create(10000, "foo")
local nextState = table.move(prevState, 2, 9998, 2, { [1] = "bar", [9999] = "bar" })
local start = os.clock()
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
assert(os.clock() - start < 10e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`)
for index in next, prevState do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
end
end)
test("cannot preserve sparse arrays", function() test("cannot preserve sparse arrays", function()
local prevState = {} local prevState = {}
local nextState = { [2] = "foo" } local nextState = { [2] = "foo" }
local patches = serdes(patch.diff(prevState, nextState)) local patches = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches) local applied = patch.apply(prevState, patches)
-- Not enough information is available to determine whether 'patches' -- Not enough information is available to determine whether 'patches'
@ -102,24 +104,30 @@ test("cannot preserve sparse arrays", function()
end) end)
test("optionally fixes sparse arrays", function() test("optionally fixes sparse arrays", function()
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do config.autoSerialize = true
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
assert(type(index) == "string", `expected {type(index)} {index} to be string`) assert(type(index) == "string", `expected {type(index)} {index} to be string`)
end end
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do config.autoSerialize = false
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
assert(type(index) == "number", `expected {type(index)} {index} to be number`) assert(type(index) == "number", `expected {type(index)} {index} to be number`)
end end
end) end)
test("optionally validates state", function() test("optionally validates state", function()
local success = pcall(function() local success = pcall(function()
patch.diff({}, { [0.1] = true }, true) config.validatePatches = true
patch.diff({}, { [0.1] = true })
end) end)
assert(not success, "should not allow non-integer keys") assert(not success, "should not allow non-integer keys")
success = pcall(function() success = pcall(function()
patch.diff({}, { [0.1] = true }, false) config.validatePatches = false
patch.diff({}, { [0.1] = true })
end) end)
assert(success, "should skip validation if not requested") assert(success, "should skip validation if not requested")

View file

@ -1,99 +0,0 @@
local Charm = require("../../Charm")
local CharmSync = require("../")
local suite = require("../../../../test/suite")
type SyncPayload = CharmSync.SyncPayload
local atom = Charm.atom
local server = CharmSync.server
local patch = CharmSync.patch
local test = suite.test
local target: Player = {} :: any
test("sends state to new players", function()
local a = atom(1)
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...)
player, payload = ...
end)
server:hydrate(target)
assert(player == target, "should send to the target player")
assert(payload.type == "init", "should be an init payload")
assert(payload.data.a == 1, "should send the initial state")
disconnect()
end)
test("sends state patches", function()
local a = atom({ b = 1, c = 2 } :: { [string]: number? })
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...)
player, payload = ...
end)
a({ b = 1, c = 5 })
server:_sendPatch(target)
assert(player == target, "should send to the target player")
assert(payload.type == "patch", "should be a patch payload")
assert(payload.data.a.b == nil, "should not send unchanged values")
assert(payload.data.a.c == 5, "should send changed values")
a({ b = 1, d = 1 })
server:_sendPatch(target)
assert(payload.data.a.b == nil, "should not send unchanged values")
assert(patch.isNone(payload.data.a.c) == true, "should send None for removed values")
assert(payload.data.a.d == 1, "should send new values")
disconnect()
end)
test("preserves history", function()
local a = atom(1)
local b = atom(1)
local payloads: { SyncPayload }
local server = server({
atoms = { a = a, b = b },
interval = -1,
preserveHistory = true,
})
local disconnect = server:connect(function(_, ...)
payloads = { ... }
end)
a(2)
b(2)
server:_sendPatch(target)
assert(#payloads == 1, "should only have one payload")
assert(payloads[1].type == "patch", "should be a patch payload")
assert(payloads[1].data.a == 2, "should send the new value of a")
assert(payloads[1].data.b == 2, "should send the new value of b")
a(3)
a(4)
b(3)
server:_sendPatch(target)
assert(#payloads == 2, "should have two payloads")
assert(payloads[1].type == "patch", "1 should be a patch payload")
assert(payloads[1].data.a == 3, "1 should send the new value of a")
assert(payloads[1].data.b == nil, "1 should not send unchanged values")
assert(payloads[2].type == "patch", "2 should be a patch payload")
assert(payloads[2].data.a == 4, "2 should send the new value of a")
assert(payloads[2].data.b == 3, "2 should send the new value of b")
disconnect()
end)
return {}

View file

@ -4,10 +4,6 @@ suite.describe("client", function()
require("./client.spec") require("./client.spec")
end) end)
suite.describe("server", function()
require("./server.spec")
end)
suite.describe("flatten", function() suite.describe("flatten", function()
require("./flatten.spec") require("./flatten.spec")
end) end)

View file

@ -1,9 +1,8 @@
local Charm = require("../../Charm") local Charm = require("../../Charm")
local CharmSync = require("../")
local suite = require("../../../../test/suite") local suite = require("../../../../test/suite")
local validate = require("../validate")
local atom = Charm.atom local atom = Charm.atom
local validate = CharmSync.validate
local test = suite.test local test = suite.test
test("validates inputs", function() test("validates inputs", function()

View file

@ -2,75 +2,72 @@ local Charm = require("../Charm")
local patch = require("./patch") local patch = require("./patch")
local types = require("./types") local types = require("./types")
type AtomMap = types.AtomMap
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
type ClientOptions = { local atomRegister: { [string]: Charm.Atom } = {}
--- The atoms to synchronize with the server.
atoms: AtomMap,
--- Whether to ignore patches sent before the client has been hydrated.
--- Default is `true`.
ignoreUnhydrated: boolean?,
}
type ClientSyncer = { --- @client
--- Applies a patch or initializes the state of the atoms with the given local function registerAtoms(atoms: { [string]: Charm.Atom })
--- payload from the server. for key, atom in atoms do
--- if atomRegister[key] and atomRegister[key] ~= atom then
--- @param ...payloads The patches or hydration payloads to apply. warn("[Charm] Attempted to sync different atoms with the same key:", key)
sync: (self: ClientSyncer, ...SyncPayload) -> (), continue
} end
--- Creates a `ClientSyncer` object that receives patches from the server and atomRegister[key] = atom
--- applies them to the local state. end
--- end
--- @param options The atoms to synchronize with the server.
--- @return A `ClientSyncer` object.
local function client(options: ClientOptions): ClientSyncer
local atoms = options.atoms
local ignoreUnhydrated = options.ignoreUnhydrated ~= false
local self = {} :: ClientSyncer --- @client
local hydrated = false local function clearAtoms()
table.clear(atomRegister)
end
local function hydrate(state: { [string | number]: any }) --- @client
hydrated = true local function unregisterAtoms(...: string)
for index = 1, select("#", ...) do
local key = select(index, ...)
atomRegister[key] = nil
end
end
for key, value in state do local function applyInitPayload(data: { [string]: any })
local atom = atoms[key] for key, value in data do
local atom = atomRegister[key]
if atom then
atom(value) atom(value)
end end
end end
local function apply(data: { [string | number]: any })
local target = {}
for key, atom in atoms do
target[key] = atom()
end
target = patch.apply(target, data)
for key, atom in atoms do
atom(target[key])
end
end
function self:sync(...)
for index = 1, select("#", ...) do
local payload: SyncPayload = select(index, ...)
Charm.batched(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 end
return client local function applyPatchPayload(data: { [string | number]: any })
local target = {}
for key, atom in atomRegister do
target[key] = atom()
end
target = patch.apply(target, data)
for key, atom in atomRegister do
atom(target[key])
end
end
--- @client
local function applyPayloads(payloads: { SyncPayload })
for _, payload in payloads do
if payload.type == "init" then
Charm.batched(applyInitPayload, payload.data)
elseif payload.type == "patch" then
Charm.batched(applyPatchPayload, payload.data)
end
end
end
return {
registerAtoms = registerAtoms,
clearAtoms = clearAtoms,
unregisterAtoms = unregisterAtoms,
applyPayloads = applyPayloads,
}

View file

@ -0,0 +1,20 @@
return {
--- 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 = 0,
--- 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 = false,
--- When `true`, Charm will apply validation and serialize unsafe arrays
--- to address remote event argument limitations. Defaults to `true`.
--- This option should be disabled if your network library uses a custom
--- serialization method (i.e. Zap, ByteNet) to prevent interference.
autoSerialize = true,
--- When `true`, patches will be validated before being sent to clients to
--- enforce remote event limitations. Only checked in strict mode and if
--- `autoSerialize` is enabled. Defaults to `true`.
validatePatches = true,
}

View file

@ -1,13 +1,9 @@
local types = require("./types")
type AtomMap = types.AtomMap
type NestedAtomMap = { type NestedAtomMap = {
[string]: NestedAtomMap | () -> (), [string]: NestedAtomMap | () -> any,
} }
local function flatten(atoms: NestedAtomMap): AtomMap local function flatten(atoms: NestedAtomMap): { [string]: () -> any }
local result: AtomMap = {} local result: { [string]: () -> any } = {}
local function visit(node: NestedAtomMap, path: string) local function visit(node: NestedAtomMap, path: string)
for key, value in node do for key, value in node do

View file

@ -1,17 +1,18 @@
local client = require("@self/client") local client = require("@self/client")
local config = require("@self/config")
local flatten = require("@self/flatten") local flatten = require("@self/flatten")
local patch = require("@self/patch") local patch = require("@self/patch")
local server = require("@self/server") local server = require("@self/server")
local types = require("@self/types") local types = require("@self/types")
local validate = require("@self/validate")
export type None = types.None
export type SyncPayload = types.SyncPayload export type SyncPayload = types.SyncPayload
return { return {
config = config,
client = client, client = client,
server = server, server = server,
flatten = flatten,
validate = validate,
patch = patch, patch = patch,
isNone = patch.isNone, isNone = patch.isNone,
flatten = flatten,
} }

View file

@ -1,26 +0,0 @@
--- 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 = game:GetService("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

@ -1,16 +1,28 @@
local Charm = require("../Charm") local Charm = require("../Charm")
local config = require("./config")
local types = require("./types")
local validate = require("./validate") local validate = require("./validate")
local flags = Charm.flags local flags = Charm.flags
export type None = types.None
--- A special symbol that denotes the absence of a value. Used to represent --- A special symbol that denotes the absence of a value. Used to represent
--- deleted values in patches. --- deleted values in patches.
local None = { __none = "__none" } local None: None = { __none = "__none" }
--- Checks if a value is the special `None` symbol.
local function isNone(value: any): boolean local function isNone(value: any): boolean
return type(value) == "table" and value.__none == "__none" return type(value) == "table" and value.__none == "__none"
end end
--- Converts `nil` values to the special `None` symbol. This is useful when
--- preparing data for serialization, since `nil` can represent both the absence
--- of a value and the removal of a value in patches.
local function nilToNone<T>(value: T?): T | None
return if value == nil then None else value
end
--- JSON serialization can drop the last values of a sparse array. For example, --- 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 --- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
--- serialization of sparse arrays can be resolved by converting them to --- serialization of sparse arrays can be resolved by converting them to
@ -29,7 +41,7 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
local dictionary = {} local dictionary = {}
for index, value in next, object do for index, value in object do
dictionary[tostring(index)] = value dictionary[tostring(index)] = value
end end
@ -41,14 +53,11 @@ end
--- ---
--- @param prevState The previous state. --- @param prevState The previous state.
--- @param nextState The next state. --- @param nextState The next state.
--- @param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`.
--- @returns A patch that can be applied to the previous state. --- @returns A patch that can be applied to the previous state.
local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?) local function diff(prevState: { [any]: any }, nextState: { [any]: any })
serialize = serialize ~= false
local patches = table.clone(nextState) local patches = table.clone(nextState)
for key, previous in next, prevState do for key, previous in prevState do
local current = nextState[key] local current = nextState[key]
if previous == current then if previous == current then
@ -56,18 +65,20 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serial
elseif current == nil then elseif current == nil then
patches[key] = None patches[key] = None
elseif type(previous) == "table" and type(current) == "table" then elseif type(previous) == "table" and type(current) == "table" then
patches[key] = diff(previous, current, serialize) patches[key] = diff(previous, current)
end end
end end
-- Coerce sparse array patches into dictionaries if config.autoSerialize then
if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then -- Coerce sparse array patches into dictionaries
patches = stringifySparseArray(patches) if prevState[1] ~= nil or nextState[1] ~= nil then
end patches = stringifySparseArray(patches)
end
if serialize and flags.strict then if flags.strict and config.validatePatches then
for key, value in next, patches do for key, value in patches do
validate(value, key) validate(value, key)
end
end end
end end
@ -80,7 +91,7 @@ end
--- @param patches The patches to apply. --- @param patches The patches to apply.
--- @returns The next state. --- @returns The next state.
local function apply(state: any, patches: any): any local function apply(state: any, patches: any): any
if type(patches) == "table" and patches.__none == "__none" then if isNone(patches) then
return nil return nil
elseif type(state) ~= "table" or type(patches) ~= "table" then elseif type(state) ~= "table" or type(patches) ~= "table" then
return patches return patches
@ -89,7 +100,7 @@ local function apply(state: any, patches: any): any
local nextState = table.clone(state) local nextState = table.clone(state)
local stateIsArray = state[1] ~= nil local stateIsArray = state[1] ~= nil
for key, patch in next, patches do for key, patch in patches do
-- Diff-checking an array produces a sparse array, which will not be -- Diff-checking an array produces a sparse array, which will not be
-- preserved when converted to JSON. To prevent this, we turn string -- preserved when converted to JSON. To prevent this, we turn string
-- keys back into numeric keys. -- keys back into numeric keys.
@ -105,6 +116,7 @@ end
return { return {
isNone = isNone, isNone = isNone,
nilToNone = nilToNone,
diff = diff, diff = diff,
apply = apply, apply = apply,
} }

View file

@ -1,250 +1,265 @@
local Charm = require("../Charm") local Charm = require("../Charm")
local interval = require("./interval") local config = require("./config")
local patch = require("./patch") local patch = require("./patch")
local types = require("./types") local types = require("./types")
type AtomMap = types.AtomMap
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
type ServerOptions = { type RegisteredAtom<T = any> = {
--- The atoms to synchronize with the client. atom: () -> T,
atoms: AtomMap, value: T,
--- The interval at which to send patches to the client, in seconds. valueLastSynced: T,
--- Defaults to `0` (patches are sent up to once per frame). Set to a stop: () -> (),
--- 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?,
--- When `true`, Charm will apply validation and serialize unsafe arrays
--- to address remote event argument limitations. Defaults to `true`.
---
--- This option should be disabled if your network library uses a custom
--- serialization method (i.e. Zap, ByteNet) to prevent interference.
autoSerialize: boolean?,
} }
type ServerSyncer = { type SyncStatus = number
--- 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 local PENDING_INITIAL_STATE: SyncStatus = 0
--- hydrates the client's state. local LISTENING_FOR_CHANGES: SyncStatus = 1
---
--- @param options The atoms to synchronize with the client.
--- @return A `ServerSyncer` object.
local function server(options: ServerOptions): ServerSyncer
local atoms = options.atoms
local autoSerialize = options.autoSerialize ~= false
local preserveHistory = options.preserveHistory
local syncRate = options.interval or 0
local self = {} :: ServerSyncer local atomRegister: { [string]: RegisteredAtom } = {}
local sync: (player: Player, payload: SyncPayload) -> () local clientRegister: { [any]: { [string]: SyncStatus } } = {}
local globalUpdates: { { key: string, value: any, previousValue: any } } = {}
local connection: RBXScriptConnection?
local function createSnapshot() local function registerAtom<T>(key: string, atom: () -> T)
local snapshot = {} if atomRegister[key] then
if atomRegister[key].atom ~= atom then
for key, atom in atoms do warn("[Charm] Attempted to sync different atoms with the same key:", key)
snapshot[key] = atom()
end end
return
return snapshot
end end
-- Send the initial state to a player when they join the server. local registeredAtom: RegisteredAtom<T> = {
function self:hydrate(player) atom = atom,
assert(sync, "connect() must be called before hydrate()") value = atom(),
valueLastSynced = atom(),
stop = function() end,
}
sync(player, { registeredAtom.stop = Charm.effect(function()
type = "init", local value = atom()
data = createSnapshot(),
})
end
-- If the history of state changes should be preserved, keep a list of if config.preserveHistory and registeredAtom.value ~= value then
-- previous snapshots and diff-check each one from the last. table.insert(globalUpdates, {
if preserveHistory then key = key,
local snapshots = { createSnapshot() } value = value,
local changed = false previousValue = registeredAtom.value,
})
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 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 snapshots do
lastSnapshot = snapshot
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
})
end
snapshots = { lastSnapshot }
changed = false
for _, player in game:GetService("Players"):GetPlayers() do
callback(player, unpack(payloads))
end
end, syncRate)
return function()
disconnect()
for _, unsubscribe in subscriptions do
unsubscribe()
end
end
end end
function self:_sendPatch(player: Player) registeredAtom.value = value
assert(sync, "connect() must be called before _sendPatch()") end)
if not changed then atomRegister[key] = registeredAtom
return
end
local payloads: { SyncPayload } = {}
for index, snapshot in snapshots do
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
})
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 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, autoSerialize),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
for _, player in game:GetService("Players"):GetPlayers() do
callback(player, payload)
end
end, syncRate)
return function()
disconnect()
for _, unsubscribe in 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, autoSerialize),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
sync(player, payload)
end
return self
end end
return server local function unregisterAtom(key: string)
local registeredAtom = atomRegister[key]
if registeredAtom then
registeredAtom.stop()
atomRegister[key] = nil
end
end
local function unregisterUnusedAtoms()
local used: { [string]: boolean } = {}
for _, clientAtoms in clientRegister do
for key in clientAtoms do
used[key] = true
end
end
for key in atomRegister do
if not used[key] then
unregisterAtom(key)
end
end
end
local function createHistoricalPatches(): { any }
local patchCount = #globalUpdates
local patches: { any } = table.create(patchCount)
for index, update in globalUpdates do
patches[index] = patch.diff(update.previousValue, update.value)
end
return patches
end
local function createHistoricalPayloadsForClient(client: any, patches: { any }): { SyncPayload }
local syncedAtoms = clientRegister[client]
if not syncedAtoms then
return {}
end
local payloads: { SyncPayload } = {}
for index, update in globalUpdates do
local patchData = patches[index]
if not patchData then
continue
end
local key = update.key
local status = syncedAtoms[key]
if status == LISTENING_FOR_CHANGES then
table.insert(payloads, {
type = "patch",
data = { [key] = patchData },
})
elseif status == PENDING_INITIAL_STATE then
table.insert(payloads, {
type = "init",
data = { [key] = update.value },
})
syncedAtoms[key] = LISTENING_FOR_CHANGES
end
end
return payloads
end
local function createPatch(): any
local current: { [string]: any } = {}
local previous: { [string]: any } = {}
for key, registeredAtom in atomRegister do
current[key] = registeredAtom.value
previous[key] = registeredAtom.valueLastSynced
registeredAtom.valueLastSynced = registeredAtom.value
end
return patch.diff(previous, current)
end
local function createPayloadsForClient(client: any, patchData: any): { SyncPayload }
local syncedAtoms = clientRegister[client]
local payloads: { SyncPayload } = {}
if not syncedAtoms then
return payloads
end
local shouldSendInitialState = false
local initPayload: { [string]: any } = {}
local patchPayload: { [string]: any } = {}
for key, status in syncedAtoms do
local registeredAtom = atomRegister[key]
if status == LISTENING_FOR_CHANGES then
patchPayload[key] = patchData[key]
elseif status == PENDING_INITIAL_STATE then
initPayload[key] = registeredAtom.value
syncedAtoms[key] = LISTENING_FOR_CHANGES
shouldSendInitialState = true
end
end
if shouldSendInitialState then
table.insert(payloads, { type = "init", data = initPayload })
end
if next(patchPayload) ~= nil then
table.insert(payloads, { type = "patch", data = patchPayload })
end
return payloads
end
--- @server
local function syncAtomsToClient(client: any, atoms: { [string]: () -> any })
if not clientRegister[client] then
clientRegister[client] = {}
end
local syncedAtoms = clientRegister[client]
for key, atom in atoms do
registerAtom(key, atom)
if not syncedAtoms[key] then
syncedAtoms[key] = PENDING_INITIAL_STATE
end
end
end
--- @server
local function unsyncClient(client: any)
clientRegister[client] = nil
end
--- @server
local function unsyncAtomsFromClient(client: any, ...: string)
local syncedAtoms = clientRegister[client]
if not syncedAtoms then
return
end
for index = 1, select("#", ...) do
local key = select(index, ...)
syncedAtoms[key] = nil
end
if next(syncedAtoms) == nil then
clientRegister[client] = nil
end
end
--- @server
local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ())
if connection then
connection:Disconnect()
end
local elapsedTime = 0
local function sync()
local patches = if config.preserveHistory then createHistoricalPatches() else createPatch()
if Charm.flags.frozen then
Charm.deepFreeze(patches)
end
for client in clientRegister do
local payloads: { SyncPayload } = if config.preserveHistory
then createHistoricalPayloadsForClient(client, patches)
else createPayloadsForClient(client, patches)
if payloads[1] then
task.spawn(onSync, client, payloads)
end
end
unregisterUnusedAtoms()
table.clear(globalUpdates)
end
connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime: number)
elapsedTime += deltaTime
if elapsedTime >= config.interval then
elapsedTime %= config.interval
debug.profilebegin("CharmSync")
sync()
debug.profileend()
end
end)
end
return {
syncAtomsToClient = syncAtomsToClient,
unsyncClient = unsyncClient,
unsyncAtomsFromClient = unsyncAtomsFromClient,
startSync = startSync,
}

View file

@ -1,21 +1,17 @@
local Charm = require("../Charm")
type Atom<T> = Charm.Atom<T>
--- A payload that can be sent from the server to the client to synchronize --- A payload that can be sent from the server to the client to synchronize
--- state between the two. --- the states of the client's atoms with the server.
export type SyncPayload = export type SyncPayload =
| { | {
type: "init", type: "init",
data: { [string | number]: any }, data: { [string]: any },
} }
| { | {
type: "patch", type: "patch",
data: { [string | number]: any }, data: { [string]: any },
} }
export type AtomMap = { --- A special type that represents a removed value in a patch. This is used to
[string | number]: Atom<any>, --- differentiate between a value that was set to `nil` and an unchanged value.
} export type None = { __none: "__none" }
return nil return nil

View file

@ -2,25 +2,26 @@ local SAFE_KEYS = { string = true, number = true }
local UNSAFE_VALUES = { ["function"] = true, thread = true } local UNSAFE_VALUES = { ["function"] = true, thread = true }
local function isTableUnsafe(object: { [any]: any }) local function isTableUnsafe(object: { [any]: any })
local keyType = nil local key = next(object)
local objectSize = 0
-- All keys must have the same type if key == nil then
for key in next, object do -- An empty table is always safe
local currentType = type(key) return false
if not keyType and SAFE_KEYS[currentType] then
keyType = currentType
elseif keyType ~= currentType then
return true
end
objectSize += 1
end end
-- If there are more keys than the length of the array, it's an array with local keyType = type(key)
-- non-sequential keys. local keyCount = 0
if objectSize > #object and keyType == "number" then
-- Keys must either be all strings or all numbers
for key in object do
if type(key) ~= keyType then
return true
end
keyCount += 1
end
-- Array keys must be sequential
if keyType == "number" and keyCount > #object then
return true return true
end end
@ -33,19 +34,21 @@ end
--- @param key The key of the value in the table. --- @param key The key of the value in the table.
--- @error Throws an error if the value cannot be synced. --- @error Throws an error if the value cannot be synced.
local function validate(value: any, key: any) local function validate(value: any, key: any)
local typeOfKey = type(key) local typeKey = type(key)
local typeOfValue = type(value) local typeValue = type(value)
if not SAFE_KEYS[typeOfKey] then if not SAFE_KEYS[typeKey] then
error(`Invalid key type '{typeOfKey}' at key '{key}'`) error(`Invalid key type '{typeKey}' at key '{key}'`)
elseif UNSAFE_VALUES[typeOfValue] then elseif UNSAFE_VALUES[typeValue] then
error(`Invalid value type '{typeOfValue}' at key '{key}'`) error(`Invalid value type '{typeValue}' at key '{key}'`)
elseif typeOfValue == "table" then end
if typeValue == "table" then
if getmetatable(value) ~= nil then if getmetatable(value) ~= nil then
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`) error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
elseif isTableUnsafe(value) then elseif isTableUnsafe(value) then
error( error(
`Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n` `Cannot sync tables unsupported by remote events! The table was found at the key '{key}'.\n\n`
.. "This can be for the following reasons:\n" .. "This can be for the following reasons:\n"
.. "1. The object is an array with non-sequential keys\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" .. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n"
@ -54,7 +57,7 @@ local function validate(value: any, key: any)
end end
end end
if typeOfKey == "number" then if typeKey == "number" then
if key == math.huge or key == -math.huge then if key == math.huge or key == -math.huge then
error("Cannot sync infinity as key") error("Cannot sync infinity as key")
elseif key ~= math.floor(key) then elseif key ~= math.floor(key) then

View file

@ -1,5 +1,3 @@
--!nolint DeprecatedApi
local batched = require("@self/batched") local batched = require("@self/batched")
local core = require("@self/core") local core = require("@self/core")
local flags = require("@self/flags") local flags = require("@self/flags")
@ -9,15 +7,11 @@ local observe = require("@self/observe")
local subscribe = require("@self/subscribe") local subscribe = require("@self/subscribe")
local types = require("@self/types") local types = require("@self/types")
local untracked = require("@self/untracked") local untracked = require("@self/untracked")
local utils = require("@self/utils")
export type Atom<T = any> = types.Atom<T> export type Atom<T = any> = types.Atom<T>
export type Selector<T = any> = types.Selector<T> export type Selector<T = any> = types.Selector<T>
--- @deprecated Function `batch()` is deprecated, use `batched()` instead.
local batch = batched
--- @deprecated Function `peek()` is deprecated, use `untracked()` instead.
local peek = untracked
return { return {
core = core, core = core,
flags = flags, flags = flags,
@ -32,7 +26,5 @@ return {
observe = observe, observe = observe,
subscribe = subscribe, subscribe = subscribe,
untracked = untracked, untracked = untracked,
-- Temporary aliases for backwards compatibility deepFreeze = utils.deepFreeze,
batch = batch,
peek = peek,
} }

View file

@ -8,7 +8,7 @@ suite.describe("charm", function()
require("../packages/charm/src/__tests__/spec") require("../packages/charm/src/__tests__/spec")
end) end)
suite.describe.ingame("charm-sync", function() suite.describe("charm-sync", function()
require("../packages/charm-sync/src/__tests__/spec") require("../packages/charm-sync/src/__tests__/spec")
end) end)