mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
feat!: use alien-signals for reactivity (#50)
* feat: use alien-signals * refactor: remove pnpm, fix tests * fix: `effectScope` should set scope * fix: check previous value before calling listener * refactor: improve imports * refactor: exclude re-exports from wally * refactor: embrace scope cleanup, restructure code * fix: update pkgs dir references * ci: simplify pesde install * build: install dev deps with wally * feat: add detached, add more tests * test: simplify mapped test update * chore: rephrase doc comment * perf: optimize subscribe and mapped * refactor: avoid ambiguous optional function type * refactor: add assertion error messages * perf: optimize mapped, add benches * chore: delete redundant benchmarks * perf: simplify mapped logic * test: prettify test library * test: try different gray * test: color brackets * refactor: sync 2.0.4 * refactor: use setCurrentSub instead of pauseTracking * test: improve test suite * test: separate parent from root * refactor: iterate over previous data in mapped * refactor: improve logic * style: use `---` doc comments * refactor: improve logic * chore: housekeeping * ci: add pnpm step * ci: init submodules * test: use lune * refactor: improve structure and alien-signals integration * ci: update test file path * feat: add peek and roblox-ts types * feat: add deep freeze flag, misc refactors * refactor: reduce type casting, housekeeping * refactor: rewrite untracked, update alien-signals * chore: update eslint, housekeeping * refactor: start charm-sync rewrite * fix: send init payload even if atom did not update * test: add all sync tests * feat: prepare for alpha release * feat: add peek, make untracked escape scope * fix: wrap hooks in untracked, update types * feat: add flag for inner effect tracking * refactor: add docs, remove some modules * refactor: align with alien-signals 3.0 * feat: add signal api * refactor: update charm sync api * feat: update alien-signals * refactor: remove unnecessary features, update docs * feat: finalize sync docs * docs: update sync section * docs: simplify examples * docs: capitalize headers * docs: add migration tips * docs: fix whitespace * docs: rephrase computed arg change * refactor: rename batch, misc fixes & perf updates * refactor: simplify charm sync validation * feat: update alien-signals, add recursion flag * feat: add `recursive()` * fix: allow dispose in observers * feat: add `trigger()`, refactor source * docs: update readme * chore: prepare release candidate * test: make some tests more reliable * docs: fix typo * feat: add `reactive()` * chore: bump versions * docs: reword reactive docs * docs: fix typo * feat: add `toRaw()` * fix: check for nil diffs * chore: bump versions * perf: do not create proxies for iteration * fix: make `toRaw` track all calls * fix: check none before patching mutable tables * chore: bump charm-sync version * docs: add fishing game example project * feat: sync signals that return reactive proxy * fix: refine reactive proxy logic * fix: prevent early reactive gc, alien-signals 3.1.1 * fix: wrap iterated values in `reactive()` * fix: loosen client signal types * chore: bump release candidate * fix: remove unsynced signals immediately * test: use lute for tests * chore: bump versions * ci: set up lute * refactor: improve source readability * ci: upgrade lute * build: loosen workspace version requirements * chore: bump deps * fix: update signal overload for setter (#58) * fix: add missing recursed check * test: add important vue reactivity tests * chore: fix react tests, run stylua * chore: update vide * docs: improve intro and explain getters * docs: adjust ordering by importance * docs: move listen nesting note * build: fix react peer dep for ci * chore: update lockfile * ci: use javascript eslint config * ci: ignore eslint config file * refactor: move test directories * feat: allow recursion in effects by default * release: charm v0.11.0-rc.4 * refactor: remove recursive() * refactor: reorganize repo, create deep-charm * docs: rephrase some sections * feat: export getter and setter types * docs: rephrase intro again * feat!: run listen and subscribe callback in untracked * refactor!: remove peek * docs: note peek in migration guide * release: charm v0.11.0-rc.5 * release: charm-sync v0.4.0-rc.7 * release: react-charm v0.4.0-rc.4 * fix: remove old keys when updating key in `mapped()` * refactor: shorten error traceback, housekeeping * refactor: keep error level in some cases * fix: check if cleanup functions disposed effect * refactor!: run effects in batched * ci: check eslint and prettier * perf: run observe fn in untracked * perf: call mapper as-is in `mapped()` * refactor: error for effect cleanup errors * refactor: use new type solver * ci: disable new solver flag until 0.714 * chore: housekeeping * refactor: improve errors in nonstrict, optimize noyield * chore: housekeeping * refactor: satisfy new solver * refactor: improve error traceback * docs: link roblox signals * chore: housekeeping * perf: remove `clearPending` inline operation * refactor: check type of cleanup * ci: remove selene * fix: always return pending value in signal setter * test: add multi benchmark * release: charm v0.11.0-rc.6 * test: add cleanup order tests * chore: bump deps * docs: add warning for deep-charm and note for `observe()` --------- Co-authored-by: safazi <safazics@gmail.com>
This commit is contained in:
parent
d0b7cff943
commit
573533058f
148 changed files with 9222 additions and 4384 deletions
9
packages/charm-sync/Charm.luau
Normal file
9
packages/charm-sync/Charm.luau
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
if not script then
|
||||
return require("../charm/src")
|
||||
end
|
||||
|
||||
local Charm = require(script.Parent.Parent.charm.src)
|
||||
|
||||
export type Atom<T> = Charm.Atom<T>
|
||||
|
||||
return Charm
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "CharmSync",
|
||||
"name": "charm-sync",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
{
|
||||
"name": "@rbxts/charm-sync",
|
||||
"version": "0.3.0",
|
||||
"description": "Sync server state with clients using Charm",
|
||||
"repository": "littensy/charm",
|
||||
"version": "0.4.0-rc.7",
|
||||
"description": "Simple server-to-client sync for Charm",
|
||||
"license": "MIT",
|
||||
"main": "src/init.luau",
|
||||
"types": "src/index.d.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"*.luau",
|
||||
"!wally.toml",
|
||||
"!wally.lock",
|
||||
"!default.project.json",
|
||||
"!test"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/littensy/charm.git"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "cp ../../LICENSE.md . && cp ../../README.md .",
|
||||
"prepare": "cp ../../LICENSE . && cp ../../README.md .",
|
||||
"publish:wally": "wally publish"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rbxts/charm": "workspace:*"
|
||||
"dependencies": {
|
||||
"@rbxts/charm": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,83 +1,108 @@
|
|||
local Charm = require(script.Parent.Parent.Charm)
|
||||
local patch = require(script.Parent.patch)
|
||||
local types = require(script.Parent.types)
|
||||
type AtomMap = types.AtomMap
|
||||
local Charm = require("../Charm")
|
||||
local patch = require("./patch")
|
||||
local types = require("./types")
|
||||
|
||||
type SyncPayload = types.SyncPayload
|
||||
type ReactiveProxy<T = { [any]: any }> = types.ReactiveProxy<T>
|
||||
|
||||
type ClientOptions = {
|
||||
--[=[
|
||||
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?,
|
||||
}
|
||||
local trackedSignals: { [string]: (any) -> any } = {}
|
||||
local trackedReactives: { [string]: ReactiveProxy } = {}
|
||||
|
||||
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 atoms = options.atoms
|
||||
local ignoreUnhydrated = options.ignoreUnhydrated ~= false
|
||||
|
||||
local self = {} :: ClientSyncer
|
||||
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
|
||||
local function shallowMergeReactive(reactive: ReactiveProxy, source: { [any]: any })
|
||||
local target = reactive.__target
|
||||
for key, value in source do
|
||||
target[key] = value
|
||||
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
|
||||
Charm.trigger(reactive.__track)
|
||||
end
|
||||
|
||||
return client
|
||||
--[[
|
||||
Registers a map of setters to the state with the corresponding keys on the
|
||||
server. When the server sends updates, these setters will be called with
|
||||
the new values.
|
||||
|
||||
Atoms, signals converted to atoms, and signal setter functions are allowed.
|
||||
Note that `server.addSignalsToClient` still requires some way to get the
|
||||
state.
|
||||
|
||||
@param signals A map of setter functions to sync with the server.
|
||||
]]
|
||||
local function addSignals(signals: { [string]: ((any) -> any) | {} })
|
||||
for key, signal in signals do
|
||||
if type(signal) == "function" then
|
||||
trackedSignals[key] = signal
|
||||
else
|
||||
trackedReactives[key] = (signal :: any) :: ReactiveProxy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Unregisters from server state updates for the given keys. The signals
|
||||
will retain their current values, but will no longer receive updates
|
||||
from the server.
|
||||
]]
|
||||
local function removeSignals(...: string)
|
||||
for index = 1, select("#", ...) do
|
||||
local key = select(index, ...)
|
||||
trackedSignals[key] = nil
|
||||
trackedReactives[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Unsubscribes from all server state updates.
|
||||
]]
|
||||
local function removeAllSignals()
|
||||
table.clear(trackedSignals)
|
||||
table.clear(trackedReactives)
|
||||
end
|
||||
|
||||
local function applyInitPayload(data: { [string]: any })
|
||||
for key, value in data do
|
||||
if trackedSignals[key] then
|
||||
trackedSignals[key](value)
|
||||
elseif trackedReactives[key] then
|
||||
shallowMergeReactive(trackedReactives[key], value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function applyPatchPayload(data: { [string]: any })
|
||||
for key, diffs in data do
|
||||
local trackedSignal = trackedSignals[key]
|
||||
local trackedReactive = trackedReactives[key]
|
||||
|
||||
if trackedSignal then
|
||||
trackedSignal(function(value)
|
||||
return patch.apply(value, diffs)
|
||||
end)
|
||||
elseif trackedReactive then
|
||||
patch.applyMutable(trackedReactive.__target, diffs)
|
||||
Charm.trigger(trackedReactive.__track)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Merges the server's state patches into the client's state, either
|
||||
initializing it with the full state or merging a partial patch.
|
||||
|
||||
@param payloads The state updates received from the server.
|
||||
]]
|
||||
local function applyPatches(payloads: { SyncPayload })
|
||||
for _, payload in payloads do
|
||||
if payload.type == "init" then
|
||||
Charm.batch(applyInitPayload, payload.data)
|
||||
elseif payload.type == "patch" then
|
||||
Charm.batch(applyPatchPayload, payload.data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
addSignals = addSignals,
|
||||
removeSignals = removeSignals,
|
||||
removeAllSignals = removeAllSignals,
|
||||
patch = applyPatches,
|
||||
}
|
||||
|
|
|
|||
31
packages/charm-sync/src/config.luau
Normal file
31
packages/charm-sync/src/config.luau
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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` for performance reasons.
|
||||
]]
|
||||
preserveHistory = false,
|
||||
--[[
|
||||
When `true`, Charm will modify sparse arrays to prevent data loss from
|
||||
remote event limitations. When an unsafe array is detected, numeric
|
||||
indices will be converted to string keys so that the array can be sent
|
||||
safely and then converted back to an array on the receiving end.
|
||||
|
||||
This option should be disabled if your network library uses a custom
|
||||
serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
]]
|
||||
fixArrays = true,
|
||||
--[[
|
||||
When `true`, synced state containing unsafe sparse arrays or mixed
|
||||
tables will emit a warning. Only checked in strict mode and if
|
||||
`fixArrays` is enabled. Defaults to `true`.
|
||||
]]
|
||||
validatePatches = true,
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
local types = require(script.Parent.types)
|
||||
type AtomMap = types.AtomMap
|
||||
|
||||
type NestedAtomMap = {
|
||||
[string]: NestedAtomMap | () -> (),
|
||||
}
|
||||
|
||||
local function flatten(atoms: NestedAtomMap): AtomMap
|
||||
local result: AtomMap = {}
|
||||
|
||||
local function visit(node: NestedAtomMap, path: string)
|
||||
for key, value in node do
|
||||
local location = if path == "" then key else path .. "/" .. key
|
||||
|
||||
if type(value) == "table" then
|
||||
visit(value, location)
|
||||
else
|
||||
result[location] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
visit(atoms, "")
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
return flatten
|
||||
462
packages/charm-sync/src/index.d.ts
vendored
462
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -1,214 +1,292 @@
|
|||
import { Atom, Selector } from "@rbxts/charm";
|
||||
|
||||
export = CharmSync;
|
||||
export as namespace CharmSync;
|
||||
|
||||
type Cleanup = () => void;
|
||||
import { Atom, Setter } from "@rbxts/charm";
|
||||
|
||||
type Key = string | number | symbol;
|
||||
|
||||
declare namespace CharmSync {
|
||||
type AtomMap = Record<string, Atom<any>>;
|
||||
/**
|
||||
* Represents the removal of a value from the state.
|
||||
*/
|
||||
export interface None {
|
||||
readonly __none: "__none";
|
||||
}
|
||||
|
||||
type SelectorMap = Record<string, Selector<any>>;
|
||||
export type MaybeNone<T> = undefined extends T ? None : never;
|
||||
|
||||
type ReactiveObject = { [key: Key]: any };
|
||||
|
||||
type ServerSignalMap = Record<string, (() => any) | ReactiveObject>;
|
||||
|
||||
type ClientSignalMap = Record<string, Setter<any> | ReactiveObject>;
|
||||
|
||||
/**
|
||||
* Infers the type of the return values produced by a map of functions.
|
||||
*/
|
||||
export type StateOfMap<T> = {
|
||||
readonly [P in keyof T]: T[P] extends () => infer State ? State : never;
|
||||
};
|
||||
|
||||
type DataTypes = {
|
||||
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
|
||||
};
|
||||
|
||||
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 `isNone` function.
|
||||
*/
|
||||
// TODO: Optimize type, this looks overly complex
|
||||
export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State> | State extends
|
||||
| ReadonlyMap<infer K, infer V>
|
||||
| Map<infer K, infer V>
|
||||
? ReadonlyMap<K, SyncPatch<V, FixArrays> | None>
|
||||
: State extends Set<infer T> | ReadonlySet<infer T>
|
||||
? ReadonlyMap<T, true | None>
|
||||
: State extends readonly (infer T)[]
|
||||
? FixArrays extends true
|
||||
? ReadonlyMap<string | number, SyncPatch<T, FixArrays> | None>
|
||||
: readonly (SyncPatch<T, FixArrays> | None | undefined)[]
|
||||
: State extends DataType
|
||||
? State
|
||||
: State extends object
|
||||
? { readonly [P in keyof State]?: SyncPatch<State[P], FixArrays> }
|
||||
: State;
|
||||
|
||||
/**
|
||||
* A payload that can be sent from the server to the client to synchronize
|
||||
* state between the two.
|
||||
*/
|
||||
export type SyncPayload<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true> =
|
||||
| { type: "init"; data: StateOfMap<Getters> }
|
||||
| { type: "patch"; data: SyncPatch<StateOfMap<Getters>, FixArrays> };
|
||||
|
||||
/**
|
||||
* Global configuration options that affect the behavior of Charm Sync.
|
||||
*/
|
||||
export const config: {
|
||||
/**
|
||||
* @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.
|
||||
* 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.
|
||||
*
|
||||
* @server
|
||||
* @param options The atoms to synchronize with the client.
|
||||
* @returns A `ServerSyncer` object.
|
||||
*/
|
||||
function server<Selectors extends SelectorMap, Serialize extends boolean = true>(
|
||||
options: ServerOptions<Selectors, Serialize>,
|
||||
): ServerSyncer<Selectors, Serialize>;
|
||||
|
||||
interval: number;
|
||||
/**
|
||||
* Checks whether a value is `None`. If `true`, the value is scheduled to be
|
||||
* removed from the state when the patch is applied.
|
||||
* 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` for performance reasons.
|
||||
*
|
||||
* @param value The value to check.
|
||||
* @returns `true` if the value is `None`, otherwise `false`.
|
||||
* @server
|
||||
*/
|
||||
function isNone(value: unknown): value is None;
|
||||
|
||||
interface NestedAtomMap {
|
||||
readonly [key: string]: NestedAtomMap | Atom<any>;
|
||||
}
|
||||
|
||||
type AppendPath<Path extends Key | undefined, Name extends Key> = undefined extends Path
|
||||
? Name
|
||||
: Name extends string
|
||||
? Path extends string
|
||||
? `${Path}/${Name}`
|
||||
: never
|
||||
: never;
|
||||
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
||||
|
||||
type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
|
||||
|
||||
type FlattenNestedAtoms<Atoms extends NestedAtomMap, Path extends Key | undefined = undefined> = IntersectValues<{
|
||||
readonly [Name in keyof Atoms as AppendPath<Path, Name>]: Atoms[Name] extends Atom<any>
|
||||
? { readonly [K in AppendPath<Path, Name>]: Atoms[Name] }
|
||||
: Atoms[Name] extends NestedAtomMap
|
||||
? FlattenNestedAtoms<Atoms[Name], AppendPath<Path, Name>>
|
||||
: never;
|
||||
}>;
|
||||
|
||||
preserveHistory: boolean;
|
||||
/**
|
||||
* Flattens a nested atom map into a single object with slash-separated
|
||||
* keys. Useful for recursively collecting atoms returned by modules.
|
||||
* When `true`, Charm will modify sparse arrays to prevent data loss from
|
||||
* remote event limitations. When an unsafe array is detected, numeric
|
||||
* indices will be converted to string keys so that the array can be sent
|
||||
* safely and then converted back to an array on the receiving end.
|
||||
*
|
||||
* @param atoms The nested atom map to flatten.
|
||||
* @return A flattened atom map.
|
||||
*/
|
||||
function flatten<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>;
|
||||
|
||||
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.
|
||||
* This option should be disabled if your network library uses a custom
|
||||
* serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
*
|
||||
* If a value was removed, it is replaced with `None`. This can be checked
|
||||
* using the `sync.isNone` function.
|
||||
* @server
|
||||
*/
|
||||
type SyncPatch<State, Serialize extends boolean = true> = MaybeNone<State> | State extends
|
||||
| ReadonlyMap<infer K, infer V>
|
||||
| Map<infer K, infer V>
|
||||
? ReadonlyMap<K, SyncPatch<V, Serialize> | None>
|
||||
: State extends Set<infer T> | ReadonlySet<infer T>
|
||||
? ReadonlyMap<T, true | None>
|
||||
: State extends readonly (infer T)[]
|
||||
? Serialize extends true
|
||||
? ReadonlyMap<string | number, SyncPatch<T, Serialize> | None>
|
||||
: readonly (SyncPatch<T, Serialize> | None | undefined)[]
|
||||
: State extends DataType
|
||||
? State
|
||||
: State extends object
|
||||
? { readonly [P in keyof State]?: SyncPatch<State[P], Serialize> }
|
||||
: State;
|
||||
fixArrays: boolean;
|
||||
/**
|
||||
* When `true`, synced state containing unsafe sparse arrays or mixed
|
||||
* tables will emit a warning. Only checked in strict mode and if
|
||||
* `fixArrays` is enabled. Defaults to `true`.
|
||||
*
|
||||
* @server
|
||||
*/
|
||||
validatePatches: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @client
|
||||
*/
|
||||
export namespace client {
|
||||
/**
|
||||
* Registers a map of setters to the state with the corresponding keys on the
|
||||
* server. When the server sends updates, these setters will be called with
|
||||
* the new values.
|
||||
*
|
||||
* Atoms, signals converted to atoms, and signal setter functions are allowed.
|
||||
* Note that `server.addSignalsToClient` still requires some way to get the
|
||||
* state.
|
||||
*
|
||||
* @param signals A map of setter functions to sync with the server.
|
||||
*/
|
||||
export function addSignals<Signals extends ClientSignalMap = ClientSignalMap>(signals: Signals): void;
|
||||
|
||||
/**
|
||||
* A payload that can be sent from the server to the client to synchronize
|
||||
* state between the two.
|
||||
* Unregisters from server state updates for the given keys. The signals
|
||||
* will retain their current values, but will no longer receive updates
|
||||
* from the server.
|
||||
*/
|
||||
type SyncPayload<Selectors extends SelectorMap, Serialize extends boolean = true> =
|
||||
| { readonly type: "init"; readonly data: StateOfMap<Selectors> }
|
||||
| { readonly type: "patch"; readonly data: SyncPatch<StateOfMap<Selectors>, Serialize> };
|
||||
export function removeSignals<Signals extends ServerSignalMap | ClientSignalMap = ClientSignalMap>(
|
||||
...keys: (keyof Signals)[]
|
||||
): void;
|
||||
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* Unsubscribes from all server state updates.
|
||||
*/
|
||||
export function removeAllSignals(): void;
|
||||
|
||||
interface ServerOptions<Selectors extends SelectorMap, Serialize extends boolean = true> {
|
||||
/**
|
||||
* 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`, 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?: Serialize;
|
||||
}
|
||||
|
||||
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, boolean>[]): void;
|
||||
}
|
||||
|
||||
interface ServerSyncer<Selectors extends SelectorMap, Serialize extends boolean> {
|
||||
/**
|
||||
* 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, Serialize>[]) => 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;
|
||||
}
|
||||
/**
|
||||
* Merges the server's state patches into the client's state, either
|
||||
* initializing it with the full state or merging a partial patch.
|
||||
*
|
||||
* @param payloads The state updates received from the server.
|
||||
*/
|
||||
export function patch<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
|
||||
payloads: SyncPayload<Getters, FixArrays>[],
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @server
|
||||
*/
|
||||
export namespace server {
|
||||
/**
|
||||
* Subscribes a client to the given signals. When an update occurs, the client
|
||||
* will receive a partial state patch to merge into their local state. May be
|
||||
* called multiple times to subscribe to additional keys.
|
||||
*
|
||||
* Atoms, computed signals, and signal getter functions are allowed. Note
|
||||
* that `client.addSignals` still requires some way to set the state.
|
||||
*
|
||||
* @param client The client receiving state updates.
|
||||
* @param signals A map of getter functions to sync with the client.
|
||||
*/
|
||||
export function addSignalsToClient<Signals extends ServerSignalMap = ServerSignalMap>(
|
||||
client: Player,
|
||||
signals: Signals,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Unsubscribes a client from receiving all state updates. To only unsubscribe
|
||||
* from specific keys, use `removeSignalsFromClient` instead.
|
||||
*
|
||||
* @param client The client receiving state updates.
|
||||
*/
|
||||
export function removeClient(client: Player): void;
|
||||
|
||||
/**
|
||||
* Unsubscribes a client from receiving updates for the given keys.
|
||||
*
|
||||
* @param client The client receiving state updates.
|
||||
* @param keys The keys of the state to unsubscribe from.
|
||||
*/
|
||||
export function removeSignalsFromClient<Signals extends ServerSignalMap = ServerSignalMap>(
|
||||
client: Player,
|
||||
...keys: (keyof Signals)[]
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Uses the callback to send state updates to clients that are subscribed
|
||||
* to state changes. Starts automatically sending updates at the interval
|
||||
* specified in `config.interval`.
|
||||
*
|
||||
* @param onSync Called when sending patches to a client.
|
||||
*/
|
||||
export function connect<Getters extends ServerSignalMap = ServerSignalMap, FixArrays extends boolean = true>(
|
||||
onSync: (client: Player, payloads: SyncPayload<Getters, FixArrays>[]) => void,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Stops syncing state updates to clients at the automatic interval.
|
||||
* Flushing can still be performed manually via `flush`.
|
||||
*/
|
||||
export function disconnect(): void;
|
||||
|
||||
/**
|
||||
* Immediately sends pending updates to all clients. Normally, updates are
|
||||
* sent at the interval specified in `config.interval`, but this function
|
||||
* can be used to force an immediate sync.
|
||||
*/
|
||||
export function flush(): void;
|
||||
|
||||
/**
|
||||
* Unsubscribes all clients, stops tracking all state, and disconnects
|
||||
* from the automatic sync interval.
|
||||
*/
|
||||
export function reset(): void;
|
||||
}
|
||||
|
||||
export namespace patch {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function nilToNone<T>(value: T | undefined): T | None;
|
||||
|
||||
/**
|
||||
* Compares two states and returns a patch that can be applied to the
|
||||
* current state to produce the next state.
|
||||
*
|
||||
* @param currentState The current state.
|
||||
* @param nextState The next state.
|
||||
* @returns A patch that can be applied to the current state.
|
||||
*/
|
||||
export function diff<State, FixArrays extends boolean = true>(
|
||||
currentState: State,
|
||||
nextState: State,
|
||||
): SyncPatch<State, FixArrays>;
|
||||
|
||||
/**
|
||||
* Applies a patch to a state and returns the resulting value.
|
||||
*
|
||||
* @param currentState The current state.
|
||||
* @param statePatch The patches to apply.
|
||||
* @returns The new state with the patch applied.
|
||||
*/
|
||||
export function applyImmutable<State, FixArrays extends boolean = true>(
|
||||
currentState: State,
|
||||
statePatch: SyncPatch<State, FixArrays>,
|
||||
): State;
|
||||
}
|
||||
|
||||
type DeepFlattenImpl<T, Prefix extends string = ""> = {
|
||||
[K in keyof T]: T[K] extends DataType | Callback
|
||||
? { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] }
|
||||
: T[K] extends object
|
||||
? DeepFlattenImpl<T[K], `${Prefix}${Exclude<K, symbol>}/`>
|
||||
: { [P in `${Prefix}${Exclude<K, symbol>}`]: T[K] };
|
||||
}[keyof T];
|
||||
|
||||
type DeepFlatten<T> = Reconstruct<UnionToIntersection<DeepFlattenImpl<T>>>;
|
||||
|
||||
/**
|
||||
* Flattens a nested table into a single table with slash-separated keys.
|
||||
* Useful for recursively collecting atoms returned by modules.
|
||||
*
|
||||
* @param input A nested table.
|
||||
* @return A flattened table with slash-separated keys.
|
||||
*/
|
||||
export function flatten<T>(input: T): DeepFlatten<T>;
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
export function isNone(value: unknown): value is None;
|
||||
|
||||
/**
|
||||
* Converts a signal getter and setter into an atom. This is useful for
|
||||
* syncing client signals with the server, as the sync system requires both
|
||||
* the getter and setter to update state.
|
||||
*
|
||||
* @param getter A function that returns the current value of the signal.
|
||||
* @param setter A function that sets the value of the signal.
|
||||
* @returns An atom that can either get or set the signal's value.
|
||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#signaltoatomgetter-setter
|
||||
*/
|
||||
export function signalToAtom<T>(getter: () => T, setter: (value: ((prev: T) => T) | T) => T): Atom<T>;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
local client = require(script.client)
|
||||
local flatten = require(script.flatten)
|
||||
local patch = require(script.patch)
|
||||
local server = require(script.server)
|
||||
local types = require(script.types)
|
||||
local client = require("@self/client")
|
||||
local config = require("@self/config")
|
||||
local patch = require("@self/patch")
|
||||
local server = require("@self/server")
|
||||
local types = require("@self/types")
|
||||
local utils = require("@self/utils")
|
||||
|
||||
export type None = types.None
|
||||
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 {
|
||||
config = config,
|
||||
client = client,
|
||||
server = server,
|
||||
flatten = flatten,
|
||||
patch = patch,
|
||||
isNone = patch.isNone,
|
||||
flatten = utils.flatten,
|
||||
signalToAtom = utils.signalToAtom,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,28 +1,45 @@
|
|||
local validate = require(script.Parent.validate)
|
||||
local Charm = require("../Charm")
|
||||
local config = require("./config")
|
||||
local types = require("./types")
|
||||
local validate = require("./validate")
|
||||
|
||||
local __DEV__ = _G.__DEV__
|
||||
local flags = Charm.flags
|
||||
|
||||
--[=[
|
||||
A special symbol that denotes the absence of a value. Used to represent
|
||||
deleted values in patches.
|
||||
]=]
|
||||
local None = { __none = "__none" }
|
||||
export type None = types.None
|
||||
|
||||
local None: None = { __none = "__none" }
|
||||
|
||||
--[[
|
||||
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.
|
||||
@return `true` if the value is `None`, otherwise `false`.
|
||||
]]
|
||||
local function isNone(value: any): boolean
|
||||
return type(value) == "table" and value.__none == "__none"
|
||||
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,
|
||||
`{ [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
|
||||
The `patch` functions 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 function stringifySparseArray(object: { [any]: any }): { [string | number]: any }
|
||||
local maxn = table.maxn(object)
|
||||
|
||||
if maxn == 0 or maxn == #object then
|
||||
|
|
@ -32,86 +49,91 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
|
|||
|
||||
local dictionary = {}
|
||||
|
||||
for index, value in next, object do
|
||||
for index, value in 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.
|
||||
local function diff(oldState: any, newState: any): any
|
||||
if oldState == newState then
|
||||
return nil
|
||||
elseif newState == nil then
|
||||
return None
|
||||
elseif type(oldState) ~= "table" or type(newState) ~= "table" then
|
||||
return newState
|
||||
end
|
||||
|
||||
@param prevState The previous 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.
|
||||
]=]
|
||||
local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?)
|
||||
serialize = serialize ~= false
|
||||
local diffs = table.clone(newState)
|
||||
|
||||
local patches = table.clone(nextState)
|
||||
for key, oldValue in oldState do
|
||||
diffs[key] = diff(oldValue, newState[key])
|
||||
end
|
||||
|
||||
for key, previous in next, prevState do
|
||||
local current = nextState[key]
|
||||
if next(diffs) == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
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, serialize)
|
||||
if config.fixArrays then
|
||||
-- Convert sparse array patches that could be misserialized
|
||||
if oldState[1] ~= nil or newState[1] ~= nil then
|
||||
diffs = stringifySparseArray(diffs)
|
||||
end
|
||||
if flags.strict and config.validatePatches then
|
||||
validate(diffs)
|
||||
end
|
||||
end
|
||||
|
||||
-- Coerce sparse array patches into dictionaries
|
||||
if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then
|
||||
patches = stringifySparseArray(patches)
|
||||
end
|
||||
|
||||
if serialize and __DEV__ then
|
||||
for key, value in next, patches do
|
||||
validate(value, key)
|
||||
end
|
||||
end
|
||||
|
||||
return patches
|
||||
return diffs
|
||||
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
|
||||
local function apply(oldState: any, diffs: any): any
|
||||
if isNone(diffs) then
|
||||
return nil
|
||||
elseif type(state) ~= "table" or type(patches) ~= "table" then
|
||||
return patches
|
||||
elseif type(oldState) ~= "table" or type(diffs) ~= "table" then
|
||||
return diffs
|
||||
end
|
||||
|
||||
local nextState = table.clone(state)
|
||||
local stateIsArray = state[1] ~= nil
|
||||
local newState = table.clone(oldState)
|
||||
local stateIsArray = oldState[1] ~= nil
|
||||
|
||||
for key, patch in next, patches do
|
||||
for key, value in diffs 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)
|
||||
newState[key] = apply(newState[key], value)
|
||||
end
|
||||
|
||||
return nextState
|
||||
return newState
|
||||
end
|
||||
|
||||
local function applyMutable(targetState: any, diffs: any): any
|
||||
if isNone(diffs) then
|
||||
return nil
|
||||
elseif type(targetState) ~= "table" or type(diffs) ~= "table" then
|
||||
return diffs
|
||||
end
|
||||
|
||||
local targetIsArray = targetState[1] ~= nil
|
||||
|
||||
for key, value in diffs do
|
||||
if targetIsArray and type(key) == "string" then
|
||||
key = tonumber(key) or key
|
||||
end
|
||||
targetState[key] = applyMutable(targetState[key], value)
|
||||
end
|
||||
|
||||
return targetState
|
||||
end
|
||||
|
||||
return {
|
||||
isNone = isNone,
|
||||
nilToNone = nilToNone,
|
||||
diff = diff,
|
||||
apply = apply,
|
||||
applyMutable = applyMutable,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,267 +1,416 @@
|
|||
local Players = game:GetService("Players")
|
||||
local Charm = require("../Charm")
|
||||
local config = require("./config")
|
||||
local patch = require("./patch")
|
||||
local types = require("./types")
|
||||
local utils = require("./utils")
|
||||
|
||||
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 AtomMap = types.AtomMap
|
||||
type SyncPayload = types.SyncPayload
|
||||
|
||||
type ServerOptions = {
|
||||
--[=[
|
||||
The atoms to synchronize with the client.
|
||||
]=]
|
||||
atoms: AtomMap,
|
||||
--[=[
|
||||
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?,
|
||||
--[=[
|
||||
When `true`, Charm will apply validation and serialize unsafe arrays
|
||||
to address remote event argument limitations. Defaults to `true`.
|
||||
type SyncStatus = number
|
||||
|
||||
This option should be disabled if your network library uses a custom
|
||||
serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
]=]
|
||||
autoSerialize: boolean?,
|
||||
type ClientKeys = { [string]: SyncStatus }
|
||||
|
||||
type TrackedState<T = any> = {
|
||||
identifier: unknown,
|
||||
currentValue: T,
|
||||
lastSyncedValue: T,
|
||||
isReactive: boolean,
|
||||
dispose: () -> (),
|
||||
}
|
||||
|
||||
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) -> (),
|
||||
type StateUpdate = {
|
||||
key: string,
|
||||
newValue: any,
|
||||
oldValue: any,
|
||||
}
|
||||
|
||||
--[=[
|
||||
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 atoms = options.atoms
|
||||
local autoSerialize = options.autoSerialize ~= false
|
||||
local preserveHistory = options.preserveHistory
|
||||
local syncRate = options.interval or 0
|
||||
local PENDING_INITIAL_STATE: SyncStatus = 0
|
||||
local LISTENING_FOR_CHANGES: SyncStatus = 1
|
||||
|
||||
local self = {} :: ServerSyncer
|
||||
local sync: (player: Player, payload: SyncPayload) -> ()
|
||||
local trackedStates: { [string]: TrackedState } = {}
|
||||
local globalUpdates: { StateUpdate } = {}
|
||||
local clients: { [any]: ClientKeys } = {}
|
||||
|
||||
local function createSnapshot()
|
||||
local snapshot = {}
|
||||
local connection: RBXScriptConnection?
|
||||
local syncCallback: ((client: any, payloads: { SyncPayload }) -> ())?
|
||||
|
||||
for key, atom in next, atoms do
|
||||
snapshot[key] = atom()
|
||||
--[[
|
||||
Manually converts a reactive proxy to its raw representation to potentially
|
||||
support custom implementations of reactive proxies.
|
||||
]]
|
||||
local function toRaw<T>(value: T): T
|
||||
value = value :: types.ReactiveProxy<T>
|
||||
if type(value) == "table" and type(value.__target) == "table" and type(value.__track) == "function" then
|
||||
value.__track()
|
||||
return toRaw(value.__target)
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
local function trackSignal<T>(key: string, getter: () -> T, identifier: unknown)
|
||||
if trackedStates[key] then
|
||||
-- Warn against name collisions with conflicting signals or proxies
|
||||
if trackedStates[key].identifier ~= identifier then
|
||||
warn("[Charm] Attempted to sync different states with the same key:", key)
|
||||
end
|
||||
|
||||
return snapshot
|
||||
return
|
||||
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()")
|
||||
local trackedState: TrackedState<T>
|
||||
local initialValue = getter()
|
||||
local rawInitialValue = toRaw(initialValue)
|
||||
local snapshot = if initialValue ~= rawInitialValue then utils.deepCopy(rawInitialValue) else initialValue
|
||||
|
||||
sync(player, {
|
||||
type = "init",
|
||||
data = createSnapshot(),
|
||||
})
|
||||
end
|
||||
local dispose = Charm.effect(function()
|
||||
local newValue = getter()
|
||||
local rawValue = toRaw(newValue)
|
||||
local isReactive = newValue ~= rawValue
|
||||
|
||||
-- If the history of state changes should be preserved, keep a list of
|
||||
-- previous snapshots and diff-check each one from the last.
|
||||
if 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, 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, autoSerialize),
|
||||
})
|
||||
end
|
||||
|
||||
snapshots = { lastSnapshot }
|
||||
changed = false
|
||||
|
||||
for _, player in next, Players:GetPlayers() do
|
||||
callback(player, unpack(payloads))
|
||||
end
|
||||
end, syncRate)
|
||||
|
||||
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, 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 next, 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 next, Players:GetPlayers() do
|
||||
callback(player, payload)
|
||||
end
|
||||
end, syncRate)
|
||||
|
||||
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
|
||||
if not trackedState then
|
||||
return
|
||||
end
|
||||
|
||||
local payload: SyncPayload = {
|
||||
type = "patch",
|
||||
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
|
||||
}
|
||||
-- When preserving history, we need to keep track of previous values
|
||||
-- so we can create granular patches for each change.
|
||||
if config.preserveHistory then
|
||||
local prevSnapshot = snapshot
|
||||
snapshot = if isReactive then utils.deepCopy(rawValue) else newValue
|
||||
table.insert(globalUpdates, {
|
||||
key = key,
|
||||
newValue = snapshot,
|
||||
oldValue = prevSnapshot,
|
||||
})
|
||||
end
|
||||
|
||||
previousSnapshot = table.clone(currentSnapshot)
|
||||
changed = false
|
||||
trackedState.currentValue = rawValue
|
||||
trackedState.isReactive = isReactive
|
||||
end)
|
||||
|
||||
sync(player, payload)
|
||||
end
|
||||
trackedState = {
|
||||
identifier = identifier,
|
||||
currentValue = rawInitialValue,
|
||||
lastSyncedValue = snapshot,
|
||||
isReactive = initialValue ~= rawInitialValue,
|
||||
dispose = dispose,
|
||||
}
|
||||
|
||||
return self
|
||||
trackedStates[key] = trackedState
|
||||
end
|
||||
|
||||
return server
|
||||
local function stopTracking(key: string)
|
||||
local trackedState = trackedStates[key]
|
||||
if trackedState then
|
||||
trackedState.dispose()
|
||||
trackedStates[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function purgeUntracked()
|
||||
local active: { [string]: boolean } = {}
|
||||
|
||||
for _, clientKeys in clients do
|
||||
for key in clientKeys do
|
||||
active[key] = true
|
||||
end
|
||||
end
|
||||
|
||||
for key in trackedStates do
|
||||
if not active[key] then
|
||||
stopTracking(key)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function diffGlobalUpdateBuffer(): { any }
|
||||
local patchCount = #globalUpdates
|
||||
local patches = table.create(patchCount)
|
||||
|
||||
for _, update in globalUpdates do
|
||||
table.insert(patches, patch.diff(update.oldValue, update.newValue))
|
||||
end
|
||||
|
||||
return patches
|
||||
end
|
||||
|
||||
local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }?
|
||||
local clientKeys = clients[client]
|
||||
|
||||
if not clientKeys then
|
||||
return nil
|
||||
end
|
||||
|
||||
local payloads: { SyncPayload } = {}
|
||||
|
||||
-- Iterate over all global updates and see if the client is interested in
|
||||
-- any of them. If so, we create a patch payload for the update.
|
||||
for index, update in globalUpdates do
|
||||
local patchData = globalPatches[index]
|
||||
|
||||
if not patchData then
|
||||
continue
|
||||
end
|
||||
|
||||
local key = update.key
|
||||
local status = clientKeys[key]
|
||||
|
||||
if status == LISTENING_FOR_CHANGES then
|
||||
local payload: SyncPayload = {
|
||||
type = "patch",
|
||||
data = { [key] = patchData },
|
||||
}
|
||||
table.insert(payloads, payload)
|
||||
elseif status == PENDING_INITIAL_STATE then
|
||||
local payload: SyncPayload = {
|
||||
type = "init",
|
||||
data = { [key] = update.newValue },
|
||||
}
|
||||
table.insert(payloads, payload)
|
||||
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||
end
|
||||
end
|
||||
|
||||
-- If a pending signal was never updated, we still need to send its initial
|
||||
-- state. This can happen if a signal was registered after its last change.
|
||||
local initData = {}
|
||||
|
||||
for key, status in clientKeys do
|
||||
local trackedState = trackedStates[key]
|
||||
|
||||
if status == PENDING_INITIAL_STATE and trackedState then
|
||||
initData[key] = trackedState.currentValue
|
||||
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||
end
|
||||
end
|
||||
|
||||
if next(initData) ~= nil then
|
||||
table.insert(payloads, 1, { type = "init" :: "init", data = initData })
|
||||
end
|
||||
|
||||
return payloads
|
||||
end
|
||||
|
||||
local function diffGlobalState(): { [string]: any }?
|
||||
local currentGlobalState: { [string]: any } = {}
|
||||
local previousGlobalState: { [string]: any } = {}
|
||||
|
||||
for key, trackedState in trackedStates do
|
||||
local currentValue = if trackedState.isReactive
|
||||
then utils.deepCopy(trackedState.currentValue)
|
||||
else trackedState.currentValue
|
||||
|
||||
currentGlobalState[key] = currentValue
|
||||
previousGlobalState[key] = trackedState.lastSyncedValue
|
||||
trackedState.lastSyncedValue = currentValue
|
||||
end
|
||||
|
||||
return patch.diff(previousGlobalState, currentGlobalState)
|
||||
end
|
||||
|
||||
local function selectFromGlobalPatch(client: any, globalPatch: { [string]: any }?): { SyncPayload }?
|
||||
local clientKeys = clients[client]
|
||||
|
||||
if not clientKeys then
|
||||
return nil
|
||||
end
|
||||
|
||||
local payloads: { SyncPayload } = {}
|
||||
local initData: { [string]: any } = {}
|
||||
local patchData: { [string]: any } = {}
|
||||
|
||||
-- Create patches for initialized signals that changed, and add signals
|
||||
-- that are still pending initial state to the init payload.
|
||||
for key, status in clientKeys do
|
||||
local trackedState = trackedStates[key]
|
||||
|
||||
if status == LISTENING_FOR_CHANGES and globalPatch then
|
||||
patchData[key] = globalPatch[key]
|
||||
elseif status == PENDING_INITIAL_STATE then
|
||||
initData[key] = trackedState.currentValue
|
||||
clientKeys[key] = LISTENING_FOR_CHANGES
|
||||
end
|
||||
end
|
||||
|
||||
if next(initData) ~= nil then
|
||||
local payload: SyncPayload = {
|
||||
type = "init",
|
||||
data = initData,
|
||||
}
|
||||
table.insert(payloads, payload)
|
||||
end
|
||||
|
||||
if next(patchData) ~= nil then
|
||||
local payload: SyncPayload = {
|
||||
type = "patch",
|
||||
data = patchData,
|
||||
}
|
||||
table.insert(payloads, payload)
|
||||
end
|
||||
|
||||
return payloads
|
||||
end
|
||||
|
||||
local function startInterval(callback: () -> ())
|
||||
if not game then
|
||||
return
|
||||
end
|
||||
|
||||
if connection then
|
||||
connection:Disconnect()
|
||||
end
|
||||
|
||||
local timestamp = os.clock()
|
||||
|
||||
connection = game:GetService("RunService").Heartbeat:Connect(function()
|
||||
if config.interval >= 0 and os.clock() >= timestamp + config.interval then
|
||||
timestamp = os.clock()
|
||||
debug.profilebegin("CharmSync")
|
||||
callback()
|
||||
debug.profileend()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--[[
|
||||
Subscribes a client to the given signals. When an update occurs, the client
|
||||
will receive a partial state patch to merge into their local state. May be
|
||||
called multiple times to subscribe to additional keys.
|
||||
|
||||
Atoms, signals converted to atoms, and signal getter functions are allowed.
|
||||
Note that `client.addSignals` still requires some way to set the state.
|
||||
|
||||
@param client The client receiving state updates.
|
||||
@param signals A map of getter functions to sync with the client.
|
||||
]]
|
||||
local function addSignalsToClient(client: any, signals: { [string]: {} | () -> any })
|
||||
if not clients[client] then
|
||||
clients[client] = {}
|
||||
end
|
||||
|
||||
local clientKeys = clients[client]
|
||||
|
||||
for key, signal in signals do
|
||||
if type(signal) == "function" then
|
||||
trackSignal(key, signal, signal)
|
||||
else
|
||||
trackSignal(key, function()
|
||||
return signal
|
||||
end, signal)
|
||||
end
|
||||
|
||||
if not clientKeys[key] then
|
||||
-- Mark the signal as pending initial state so we send its full
|
||||
-- value the next time we sync.
|
||||
clientKeys[key] = PENDING_INITIAL_STATE
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Unsubscribes a client from receiving all state updates. To only unsubscribe
|
||||
from specific keys, use `unsubscribeClientFrom` instead.
|
||||
|
||||
@param client The client receiving state updates.
|
||||
]]
|
||||
local function removeClient(client: any)
|
||||
clients[client] = nil
|
||||
purgeUntracked()
|
||||
end
|
||||
|
||||
--[[
|
||||
Unsubscribes a client from receiving updates for the given keys.
|
||||
|
||||
@param client The client receiving state updates.
|
||||
@param keys The keys of the state to unsubscribe from.
|
||||
]]
|
||||
local function removeSignalsFromClient(client: any, ...: string)
|
||||
local clientKeys = clients[client]
|
||||
|
||||
if not clientKeys then
|
||||
return
|
||||
end
|
||||
|
||||
for index = 1, select("#", ...) do
|
||||
local key = select(index, ...)
|
||||
clientKeys[key] = nil
|
||||
end
|
||||
|
||||
if next(clientKeys) == nil then
|
||||
removeClient(client)
|
||||
else
|
||||
purgeUntracked()
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Immediately sends pending updates to all clients. Normally, updates are
|
||||
sent at the interval specified in `config.interval`, but this function
|
||||
can be used to force an immediate sync.
|
||||
]]
|
||||
local function flush()
|
||||
if not syncCallback then
|
||||
return
|
||||
end
|
||||
|
||||
local granular = config.preserveHistory
|
||||
local globalPatch: { [any]: any }? = if granular then diffGlobalUpdateBuffer() else diffGlobalState()
|
||||
|
||||
for client in clients do
|
||||
local payloads = if granular
|
||||
then selectFromGlobalPatchList(client, globalPatch :: { any })
|
||||
else selectFromGlobalPatch(client, globalPatch)
|
||||
|
||||
if payloads and payloads[1] then
|
||||
if task then
|
||||
task.spawn(syncCallback, client, payloads)
|
||||
else
|
||||
syncCallback(client, payloads)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.clear(globalUpdates)
|
||||
end
|
||||
|
||||
--[[
|
||||
Uses the callback to send state updates to clients that are subscribed
|
||||
to state changes. Starts automatically sending updates at the interval
|
||||
specified in `config.interval`.
|
||||
|
||||
@param onSync Called when sending patches to a client.
|
||||
]]
|
||||
local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
|
||||
syncCallback = onSync
|
||||
startInterval(flush)
|
||||
end
|
||||
|
||||
--[[
|
||||
Stops syncing state updates to clients at the automatic interval.
|
||||
Flushing can still be performed manually via `flush`.
|
||||
]]
|
||||
local function disconnect()
|
||||
if connection then
|
||||
connection:Disconnect()
|
||||
connection = nil
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Unsubscribes all clients, stops tracking all state, and disconnects
|
||||
from the automatic sync interval.
|
||||
]]
|
||||
local function reset()
|
||||
table.clear(trackedStates)
|
||||
table.clear(clients)
|
||||
table.clear(globalUpdates)
|
||||
disconnect()
|
||||
end
|
||||
|
||||
return {
|
||||
addSignalsToClient = addSignalsToClient,
|
||||
removeClient = removeClient,
|
||||
removeSignalsFromClient = removeSignalsFromClient,
|
||||
connect = connect,
|
||||
disconnect = disconnect,
|
||||
flush = flush,
|
||||
reset = reset,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,30 @@
|
|||
local Charm = require(script.Parent.Parent.Charm)
|
||||
type Atom<T> = Charm.Atom<T>
|
||||
|
||||
--[=[
|
||||
--[[
|
||||
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 },
|
||||
}
|
||||
the states of the client's atoms with the server.
|
||||
]]
|
||||
export type SyncPayload =
|
||||
| {
|
||||
type: "init",
|
||||
data: { [string]: any },
|
||||
}
|
||||
| {
|
||||
type: "patch",
|
||||
data: { [string]: any },
|
||||
}
|
||||
|
||||
export type AtomMap = {
|
||||
[string | number]: Atom<any>,
|
||||
}
|
||||
--[[
|
||||
A special type that represents a removed value in a patch. This is used to
|
||||
differentiate between a value that was set to `nil` and an unchanged value.
|
||||
]]
|
||||
export type None = { __none: "__none" }
|
||||
|
||||
return nil
|
||||
export type ReactiveProxy<T = any> = typeof(setmetatable(
|
||||
{} :: {
|
||||
__track: () -> (),
|
||||
__target: T,
|
||||
__signals: { [unknown]: () -> unknown },
|
||||
},
|
||||
{} :: { __index: T }
|
||||
))
|
||||
|
||||
return {}
|
||||
|
|
|
|||
65
packages/charm-sync/src/utils.luau
Normal file
65
packages/charm-sync/src/utils.luau
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
local Charm = require("../Charm")
|
||||
|
||||
type Atom<T> = Charm.Atom<T>
|
||||
|
||||
--[[
|
||||
Flattens a nested table into a single table with slash-separated keys.
|
||||
Useful for recursively collecting atoms returned by modules.
|
||||
|
||||
@param input A nested table.
|
||||
@return A flattened table with slash-separated keys.
|
||||
]]
|
||||
local function flatten<Flattened>(input: { [any]: any }): Flattened
|
||||
local result = {}
|
||||
|
||||
local function visit(node: { [any]: any }, path: string)
|
||||
for key, value in node do
|
||||
local location = if path == "" then tostring(key) else path .. "/" .. tostring(key)
|
||||
|
||||
if type(value) == "table" then
|
||||
visit(value, location)
|
||||
else
|
||||
result[location] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
visit(input, "")
|
||||
|
||||
return result :: any
|
||||
end
|
||||
|
||||
--[[
|
||||
Converts a signal getter and setter into an atom. This is useful for
|
||||
syncing client signals with the server, as the sync system requires both
|
||||
the getter and setter to update state.
|
||||
|
||||
@param getter A function that returns the current value of the signal.
|
||||
@param setter A function that sets the value of the signal.
|
||||
@return An atom that can either get or set the signal's value.
|
||||
]]
|
||||
local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom<T>
|
||||
return function(...)
|
||||
return if select("#", ...) == 0 then getter() else setter((...))
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Returns a deep copy of the given table. Skips tables with metatables to
|
||||
prevent copying reactive proxies.
|
||||
]]
|
||||
local function deepCopy<T>(object: T): T
|
||||
local copy: any = table.clone(object :: any)
|
||||
for key, value in copy do
|
||||
if type(value) == "table" and getmetatable(value) == nil then
|
||||
copy[key] = deepCopy(value)
|
||||
end
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
return {
|
||||
flatten = flatten,
|
||||
signalToAtom = signalToAtom,
|
||||
deepCopy = deepCopy,
|
||||
}
|
||||
|
|
@ -1,67 +1,46 @@
|
|||
local SAFE_KEYS = { string = true, number = true }
|
||||
local UNSAFE_VALUES = { ["function"] = true, thread = true }
|
||||
|
||||
local function isTableUnsafe(object: { [any]: any })
|
||||
local keyType = nil
|
||||
local objectSize = 0
|
||||
local firstKey = next(object)
|
||||
|
||||
-- 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
|
||||
if firstKey == nil then
|
||||
return false
|
||||
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
|
||||
local keyType = type(firstKey)
|
||||
local maxKey = 0
|
||||
|
||||
-- Keys must all be the same type
|
||||
for key in object do
|
||||
if type(key) ~= keyType then
|
||||
return true
|
||||
elseif type(key) == "number" and key > maxKey then
|
||||
maxKey = key
|
||||
end
|
||||
end
|
||||
|
||||
-- Array keys must be sequential
|
||||
if keyType == "number" and maxKey > #object then
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--[=[
|
||||
Validates a value to ensure it can be synced over a remote event.
|
||||
--[[
|
||||
Emits a warning if the value is a table that cannot be synced via
|
||||
RemoteEvents or RemoteFunctions. This only aims to catch common mistakes
|
||||
with remote argument limitations, such as sparse arrays or mixed tables.
|
||||
|
||||
@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 UNSAFE_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 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
|
||||
@param value The table value.
|
||||
]]
|
||||
local function validate(value: { [any]: any })
|
||||
if isTableUnsafe(value) then
|
||||
warn(
|
||||
"Cannot sync tables unsupported by remote events! This can be for the following reasons:\n"
|
||||
.. "1. The table is a sparse array with non-sequential indices\n"
|
||||
.. "2. The table is a mixed table (not all keys have the same type)\n\n"
|
||||
.. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations\n",
|
||||
value
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
137
packages/charm-sync/test/client.test.luau
Normal file
137
packages/charm-sync/test/client.test.luau
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
local Charm = require("../Charm")
|
||||
local DeepCharm = require("../../deep-charm/src")
|
||||
local client = require("../src/client")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
test.afterEach(function()
|
||||
client.removeAllSignals()
|
||||
end)
|
||||
|
||||
test("should hydrate atom", function()
|
||||
local a = Charm.atom(1)
|
||||
|
||||
client.addSignals({ a = a })
|
||||
client.patch({
|
||||
{ type = "init", data = { a = 2 } },
|
||||
})
|
||||
|
||||
assert(a() == 2, "should set a to the initial value")
|
||||
end)
|
||||
|
||||
test("should merge patch", function()
|
||||
local a = Charm.atom({ b = 1, c = 2 })
|
||||
local previous = a()
|
||||
|
||||
client.addSignals({ a = a })
|
||||
client.patch({
|
||||
{ type = "init", data = { a = a() } },
|
||||
})
|
||||
|
||||
assert(a() == previous, "should set a to the initial value")
|
||||
|
||||
client.patch({
|
||||
{ type = "patch", data = { a = { b = 3 } } },
|
||||
})
|
||||
|
||||
assert(a() ~= previous, "should apply patch")
|
||||
assert(a().b == 3, "should set b to the new value")
|
||||
assert(a().c == 2, "should not change c")
|
||||
end)
|
||||
|
||||
test("should merge patch (reactive)", function()
|
||||
local a = DeepCharm.reactive({ b = 1, c = 2 })
|
||||
local runs = 0
|
||||
|
||||
Charm.effect(function()
|
||||
local _ = a.b + a.c
|
||||
runs += 1
|
||||
end)
|
||||
|
||||
client.addSignals({ a = a })
|
||||
client.patch({
|
||||
{ type = "init", data = { a = { b = a.b, c = a.c } } },
|
||||
})
|
||||
|
||||
assert(runs == 1, "should not trigger effect for redundant init")
|
||||
|
||||
client.patch({
|
||||
{ type = "patch", data = { a = { b = 3 } } },
|
||||
})
|
||||
|
||||
assert(a.b == 3, "should set b to the new value")
|
||||
assert(a.c == 2, "should not change c")
|
||||
assert(runs == 2, "should trigger effect for patch")
|
||||
end)
|
||||
|
||||
test("should fix sparse array patches", function()
|
||||
local a = Charm.atom({ 1 })
|
||||
|
||||
client.addSignals({ a = a })
|
||||
client.patch({
|
||||
{ type = "init", data = { a = a() } },
|
||||
})
|
||||
|
||||
-- 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
|
||||
-- this and convert the string key back to a number.
|
||||
client.patch({
|
||||
{ type = "patch", data = { a = { ["2"] = 2 } } },
|
||||
})
|
||||
|
||||
assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers")
|
||||
assert(a()[2] == 2, "should convert serialized arrays to use numbers")
|
||||
end)
|
||||
|
||||
test("should merge multiple patches", function()
|
||||
local a = Charm.atom(1)
|
||||
local calls = {}
|
||||
|
||||
client.addSignals({ a = a })
|
||||
|
||||
Charm.subscribe(a, function(value)
|
||||
table.insert(calls, value)
|
||||
end)
|
||||
|
||||
client.patch({
|
||||
{ type = "init", data = { a = a() } },
|
||||
})
|
||||
|
||||
client.patch({
|
||||
{ type = "patch", data = { a = 2 } },
|
||||
{ type = "patch", data = { a = 3 } },
|
||||
})
|
||||
|
||||
assert(a() == 3, "should apply the last patch")
|
||||
assert(table.concat(calls) == "23", `wrong order: {table.concat(calls)}`)
|
||||
end)
|
||||
|
||||
test("should batch patches", function()
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local states = {}
|
||||
|
||||
client.addSignals({ a = a, b = b })
|
||||
|
||||
Charm.subscribe(a, function()
|
||||
states.b = b()
|
||||
end)
|
||||
|
||||
Charm.subscribe(b, function()
|
||||
states.a = a()
|
||||
end)
|
||||
|
||||
client.patch({
|
||||
{ type = "init", data = { a = a(), b = b() } },
|
||||
})
|
||||
|
||||
client.patch({
|
||||
{ type = "patch", data = { a = 1, b = 2 } },
|
||||
})
|
||||
|
||||
assert(a() == 1, "should set a to the new value")
|
||||
assert(b() == 2, "should set b to the new value")
|
||||
assert(states.a == 1, "sub to a should run after b updates")
|
||||
assert(states.b == 2, "sub to b should run after a updates")
|
||||
end)
|
||||
|
||||
return {}
|
||||
97
packages/charm-sync/test/e2e.test.luau
Normal file
97
packages/charm-sync/test/e2e.test.luau
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
local Charm = require("../Charm")
|
||||
local DeepCharm = require("../../deep-charm/src")
|
||||
local client = require("../src/client")
|
||||
local config = require("../src/config")
|
||||
local server = require("../src/server")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
test.afterEach(function()
|
||||
server.reset()
|
||||
client.removeAllSignals()
|
||||
config.preserveHistory = false
|
||||
end)
|
||||
|
||||
test("should replicate server state to client", function()
|
||||
local target = {}
|
||||
local serverA = Charm.atom({ count = 0 })
|
||||
local serverB = DeepCharm.reactive({ count = 0 })
|
||||
local clientA = Charm.atom({ count = -1 })
|
||||
local clientB = DeepCharm.reactive({ count = -1 })
|
||||
|
||||
client.addSignals({ a = clientA, b = clientB })
|
||||
server.addSignalsToClient(target, { a = serverA, b = serverB })
|
||||
server.connect(function(syncTarget, payloads)
|
||||
if syncTarget == target then
|
||||
client.patch(payloads)
|
||||
end
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(clientA().count == 0, "Client atom A should be initialized to 0")
|
||||
assert(clientB.count == 0, "Client reactive B should be initialized to 0")
|
||||
|
||||
serverA({ count = 1 })
|
||||
serverB.count = 1
|
||||
server.flush()
|
||||
assert(clientA().count == 1, "Client atom A should be updated to 1")
|
||||
assert(clientB.count == 1, "Client reactive B should be updated to 1")
|
||||
|
||||
local runs = -1
|
||||
Charm.effect(function()
|
||||
local _ = clientB.count
|
||||
runs += 1
|
||||
end)
|
||||
|
||||
serverA({ count = 2 })
|
||||
server.flush()
|
||||
assert(clientA().count == 2, "Client atom A should be updated to 2")
|
||||
assert(runs == 0, "Client reactive B should not trigger update")
|
||||
end)
|
||||
|
||||
test("should replicate historical server state to client", function()
|
||||
local target = {}
|
||||
local serverA = Charm.atom({ count = 0 })
|
||||
local serverB = DeepCharm.reactive({ count = 0 })
|
||||
local clientA = Charm.atom({ count = -1 })
|
||||
local clientB = DeepCharm.reactive({ count = -1 })
|
||||
|
||||
config.preserveHistory = true
|
||||
|
||||
client.addSignals({ a = clientA, b = clientB })
|
||||
server.addSignalsToClient(target, { a = serverA, b = serverB })
|
||||
server.connect(function(syncTarget, payloads)
|
||||
if syncTarget == target then
|
||||
client.patch(payloads)
|
||||
end
|
||||
end)
|
||||
|
||||
local expectedA = { -1, 0, 1, 2, 3, 4 }
|
||||
local expectedB = { -1, 0, 1, 2 }
|
||||
|
||||
Charm.effect(function()
|
||||
local expected = table.remove(expectedA, 1)
|
||||
assert(expected == clientA().count, `Client reactive A should be {expected}`)
|
||||
end)
|
||||
|
||||
Charm.effect(function()
|
||||
local expected = table.remove(expectedB, 1)
|
||||
assert(expected == clientB.count, `Client reactive B should be {expected}`)
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
|
||||
serverA({ count = 1 })
|
||||
serverA({ count = 2 })
|
||||
serverB.count = 1
|
||||
serverB.count = 2
|
||||
server.flush()
|
||||
assert(clientA().count == 2, "Client atom A should be updated to 2")
|
||||
assert(clientB.count == 2, "Client reactive B should be updated to 2")
|
||||
|
||||
serverA({ count = 3 })
|
||||
serverA({ count = 4 })
|
||||
server.flush()
|
||||
assert(clientA().count == 4, "Client atom A should be updated to 4")
|
||||
end)
|
||||
|
||||
return {}
|
||||
23
packages/charm-sync/test/init.luau
Normal file
23
packages/charm-sync/test/init.luau
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
local test = require("../../tests/test")
|
||||
|
||||
test.describe("client", function()
|
||||
require("@self/client.test")
|
||||
end)
|
||||
|
||||
test.describe("e2e", function()
|
||||
require("@self/e2e.test")
|
||||
end)
|
||||
|
||||
test.describe("utils", function()
|
||||
require("@self/utils.test")
|
||||
end)
|
||||
|
||||
test.describe("patch", function()
|
||||
require("@self/patch.test")
|
||||
end)
|
||||
|
||||
test.describe("server", function()
|
||||
require("@self/server.test")
|
||||
end)
|
||||
|
||||
return {}
|
||||
133
packages/charm-sync/test/patch.test.luau
Normal file
133
packages/charm-sync/test/patch.test.luau
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
local config = require("../src/config")
|
||||
local patch = require("../src/patch")
|
||||
local test = require("../../../tests/test")
|
||||
|
||||
local function mockArraySerdes(object: { [any]: any }): { [any]: any }
|
||||
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
|
||||
|
||||
test.afterEach(function()
|
||||
config.fixArrays = true
|
||||
config.validatePatches = true
|
||||
end)
|
||||
|
||||
test("returns a patch", function()
|
||||
local prevState = { foo = "bar", baz = "qux" }
|
||||
local nextState = { foo = "bar", baz = "quux" }
|
||||
local diffs = patch.diff(prevState, nextState)
|
||||
|
||||
assert(diffs ~= nil, "should return a patch")
|
||||
assert(diffs.baz == "quux", "should return the changed value")
|
||||
assert(diffs.foo == nil, "should omit unchanged values")
|
||||
end)
|
||||
|
||||
test("applies a patch", function()
|
||||
local prevState = { foo = "bar", baz = "qux" }
|
||||
local diffs = { baz = "quux", qaz = "qux" }
|
||||
local applied = patch.apply(prevState, diffs)
|
||||
|
||||
assert(applied ~= nil, "should return a new state")
|
||||
assert(applied.baz == "quux", "should apply the changed value")
|
||||
assert(applied.foo == "bar", "should keep the unchanged value")
|
||||
assert(applied.qaz == "qux", "should add the new value")
|
||||
end)
|
||||
|
||||
test("allows nested diffs", function()
|
||||
local prevState = { foo = { bar = "baz" } }
|
||||
local nextState = { foo = { bar = "qux" } }
|
||||
local diffs = patch.diff(prevState, nextState)
|
||||
local applied = patch.apply(prevState, diffs)
|
||||
|
||||
assert(applied.foo.bar == "qux", "should apply the nested patch")
|
||||
end)
|
||||
|
||||
test("preserves array density when applying", function()
|
||||
local prevState = { "a", "b", "c", "d" }
|
||||
local nextState = { ["2"] = "B", ["4"] = "D" }
|
||||
local applied = patch.apply(prevState, nextState)
|
||||
|
||||
assert(applied[1] == "a", "should keep a")
|
||||
assert(applied[2] == "B", "should apply B")
|
||||
assert(applied[3] == "c", "should keep c")
|
||||
assert(applied[4] == "D", "should apply D")
|
||||
|
||||
assert(applied["2"] == nil, "should convert serialized arrays to use numbers")
|
||||
assert(applied["4"] == nil, "should convert serialized arrays to use numbers")
|
||||
end)
|
||||
|
||||
test("preserves array density after serdes", function()
|
||||
-- This particular change results in a patch that cannot be serialized
|
||||
-- without losing information. Charm resolves this issue internally.
|
||||
local prevState = { "a", "b", "c", "d", "e" }
|
||||
local nextState = { "A", "b", "c", "D" }
|
||||
|
||||
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, diffs)
|
||||
|
||||
for index in prevState do
|
||||
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
|
||||
end
|
||||
|
||||
for index in applied do
|
||||
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
|
||||
end
|
||||
end)
|
||||
|
||||
test("cannot preserve sparse arrays", function()
|
||||
config.validatePatches = false
|
||||
|
||||
local prevState = {}
|
||||
local nextState = { [2] = "foo" }
|
||||
|
||||
local diffs = mockArraySerdes(patch.diff(prevState, nextState))
|
||||
local applied = patch.apply(prevState, diffs)
|
||||
|
||||
-- Not enough information is available to determine whether 'diffs'
|
||||
-- should keep the string keys added during remote event serialization.
|
||||
assert(applied["2"] ~= nil, "will fail to convert string keys to numbers")
|
||||
assert(applied[2] == nil, "will fail to convert string keys to numbers")
|
||||
end)
|
||||
|
||||
test("optionally fixes sparse arrays", function()
|
||||
config.fixArrays = true
|
||||
|
||||
for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
||||
assert(type(index) == "string", `expected {type(index)} {index} to be string`)
|
||||
end
|
||||
|
||||
config.fixArrays = false
|
||||
|
||||
for index in patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
||||
assert(type(index) == "number", `expected {type(index)} {index} to be number`)
|
||||
end
|
||||
end)
|
||||
|
||||
test("patches mutable state", function()
|
||||
local targetState: { [string]: string } = { foo = "bar", baz = "qux" }
|
||||
patch.applyMutable(targetState, { baz = "quux", qaz = "qux" })
|
||||
assert(targetState.baz == "quux", "should apply the changed value")
|
||||
assert(targetState.foo == "bar", "should keep the unchanged value")
|
||||
assert(targetState.qaz == "qux", "should add the new value")
|
||||
end)
|
||||
|
||||
test("removes table from mutable state", function()
|
||||
local targetState = { foo = { bar = "baz" } }
|
||||
patch.applyMutable(targetState, { foo = { __none = "__none" } })
|
||||
assert(targetState.foo == nil, "should remove the table")
|
||||
end)
|
||||
|
||||
return {}
|
||||
408
packages/charm-sync/test/server.test.luau
Normal file
408
packages/charm-sync/test/server.test.luau
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
local Charm = require("../Charm")
|
||||
local DeepCharm = require("../../deep-charm/src")
|
||||
local config = require("../src/config")
|
||||
local patch = require("../src/patch")
|
||||
local server = require("../src/server")
|
||||
local test = require("../../../tests/test")
|
||||
local types = require("../src/types")
|
||||
|
||||
test.afterEach(function()
|
||||
server.reset()
|
||||
end)
|
||||
|
||||
test("should replicate state changes", function()
|
||||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local c = Charm.atom(0)
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
||||
server.connect(function(...)
|
||||
target, payloads = ...
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||
assert(payloads[1].data.a == a(), "Expected init payload to contain atom 'a'")
|
||||
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
|
||||
assert(payloads[1].data.c == c(), "Expected init payload to contain atom 'c'")
|
||||
|
||||
a(1)
|
||||
b(2)
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||
assert(payloads[1].data.a == a(), "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[1].data.b == b(), "Expected patch payload to contain atom 'b'")
|
||||
end)
|
||||
|
||||
test("should replicate partial state patches", function()
|
||||
local client = {}
|
||||
local a = Charm.atom({ x = 0, y = 0 })
|
||||
local b = Charm.atom(0 :: number?)
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a, b = b })
|
||||
server.connect(function(...)
|
||||
target, payloads = ...
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||
assert(payloads[1].data.a.x == a().x, "Expected init payload to contain atom 'a.x'")
|
||||
assert(payloads[1].data.a.y == a().y, "Expected init payload to contain atom 'a.y'")
|
||||
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
|
||||
|
||||
a({ x = 1, y = 0 })
|
||||
b(nil)
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||
assert(payloads[1].data.a.x == 1, "Expected patch payload to contain atom 'a.x'")
|
||||
assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain atom 'a.y'")
|
||||
assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None")
|
||||
end)
|
||||
|
||||
test("should replicate partial state patches (reactive)", function()
|
||||
local client = {}
|
||||
local a = DeepCharm.reactive({ x = { y = 0 }, z = 0 :: number? })
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(...)
|
||||
target, payloads = ...
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||
assert(payloads[1].data.a.x ~= nil, "Expected init payload to contain value 'a.x'")
|
||||
assert(payloads[1].data.a.x.y == a.x.y, "Expected init payload to contain value 'a.x.y'")
|
||||
assert(payloads[1].data.a.z == a.z, "Expected init payload to contain value 'a.z'")
|
||||
|
||||
a.x.y = 1
|
||||
a.z = nil
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||
assert(payloads[1].data.a.x.y == 1, "Expected patch payload to contain value 'a.x'")
|
||||
assert(patch.isNone(payloads[1].data.a.z), "Expected patch payload to contain value 'a.z' as None")
|
||||
|
||||
a.z = 1
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||
assert(payloads[1].data.a.z == 1, "Expected patch payload to contain value 'a.z'")
|
||||
assert(payloads[1].data.a.x == nil, "Expected patch payload to not contain value 'a.x'")
|
||||
end)
|
||||
|
||||
test("should handle nested proxies", function()
|
||||
local client = {}
|
||||
local src = DeepCharm.reactive({ (DeepCharm.reactive({})) })
|
||||
local payloads: {}? = nil
|
||||
|
||||
server.addSignalsToClient(client, { src = src })
|
||||
server.connect(function(_, newPayloads)
|
||||
payloads = newPayloads
|
||||
end)
|
||||
server.flush()
|
||||
|
||||
payloads = nil
|
||||
server.flush()
|
||||
assert(payloads == nil, "Expected no payloads to be sent for unchanged nested proxy")
|
||||
end)
|
||||
|
||||
test("should init new clients", function()
|
||||
local clientA = {}
|
||||
local clientB = {}
|
||||
local a = Charm.atom(0)
|
||||
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(clientA, { a = a })
|
||||
server.connect(function(target, payloads)
|
||||
if target == clientA then
|
||||
payloadsA = payloads
|
||||
elseif target == clientB then
|
||||
payloadsB = payloads
|
||||
end
|
||||
end)
|
||||
|
||||
a(1)
|
||||
server.flush()
|
||||
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
|
||||
assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A")
|
||||
|
||||
a(2)
|
||||
server.addSignalsToClient(clientB, { a = a })
|
||||
server.flush()
|
||||
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
|
||||
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
|
||||
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
|
||||
assert(payloadsB[1].data.a == a(), "Expected init payload to contain atom 'a' for client B")
|
||||
end)
|
||||
|
||||
test("should init new atoms", function()
|
||||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(...)
|
||||
target, payloads = ...
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
server.addSignalsToClient(client, { b = b })
|
||||
a(1)
|
||||
b(1)
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||
assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'")
|
||||
assert(payloads[2].type == "patch", "Expected second payload to be patch")
|
||||
assert(payloads[2].data.a == a(), "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[2].data.b == nil, "Expected patch payload to not contain atom 'b'")
|
||||
end)
|
||||
|
||||
test("should only replicate to synced clients", function()
|
||||
local clientA = {}
|
||||
local clientB = {}
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(clientA, { a = a })
|
||||
server.addSignalsToClient(clientB, { b = b })
|
||||
server.connect(function(target, payloads)
|
||||
if target == clientA then
|
||||
payloadsA = payloads
|
||||
elseif target == clientB then
|
||||
payloadsB = payloads
|
||||
end
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
|
||||
assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A")
|
||||
assert(payloadsA[1].data.b == nil, "Expected init payload to not contain atom 'b' for client A")
|
||||
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
|
||||
assert(payloadsB[1].data.b == b(), "Expected init payload to contain atom 'b' for client B")
|
||||
assert(payloadsB[1].data.a == nil, "Expected init payload to not contain atom 'a' for client B")
|
||||
|
||||
payloadsA = {}
|
||||
payloadsB = {}
|
||||
a(1)
|
||||
server.flush()
|
||||
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
|
||||
assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A")
|
||||
assert(payloadsA[1].data.b == nil, "Expected patch payload to not contain atom 'b' for client A")
|
||||
assert(#payloadsB == 0, "Expected no payloads for client B")
|
||||
|
||||
payloadsA = {}
|
||||
payloadsB = {}
|
||||
b(1)
|
||||
server.flush()
|
||||
assert(#payloadsA == 0, "Expected no payloads for client A")
|
||||
assert(payloadsB[1].type == "patch", "Expected first payload to be patch for client B")
|
||||
assert(payloadsB[1].data.b == b(), "Expected patch payload to contain atom 'b' for client B")
|
||||
assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B")
|
||||
end)
|
||||
|
||||
test("should serialize state", function()
|
||||
local client = {}
|
||||
local a = Charm.atom({ 1, 2, 3, 4, 5 })
|
||||
local payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(_, p)
|
||||
payloads = p
|
||||
end)
|
||||
server.flush()
|
||||
|
||||
a({ 1, 2, 0, 4 })
|
||||
server.flush()
|
||||
assert(payloads[1].data.a["3"] == 0, "Expected index 3 to be serialized as string key")
|
||||
assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key")
|
||||
end)
|
||||
|
||||
test("should not send unchanged state", function()
|
||||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(_, p)
|
||||
payloads = p
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
|
||||
|
||||
payloads = {}
|
||||
a(0)
|
||||
server.flush()
|
||||
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
|
||||
end)
|
||||
|
||||
test.describe("historical", function()
|
||||
test.beforeEach(function()
|
||||
config.preserveHistory = true
|
||||
end)
|
||||
|
||||
test.afterEach(function()
|
||||
config.preserveHistory = false
|
||||
end)
|
||||
|
||||
test("should replicate granular state changes", function()
|
||||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local c = Charm.atom(0)
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
||||
server.connect(function(...)
|
||||
target, payloads = ...
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
|
||||
assert(payloads[1].data.b == 0, "Expected init payload to contain atom 'b'")
|
||||
assert(payloads[1].data.c == 0, "Expected init payload to contain atom 'c'")
|
||||
|
||||
a(1)
|
||||
a(2)
|
||||
b(2)
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||
assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'")
|
||||
end)
|
||||
|
||||
test("should replicate granular state changes (reactive)", function()
|
||||
local client = {}
|
||||
local a = DeepCharm.reactive({ value = 0 })
|
||||
local b = DeepCharm.reactive({ value = 0 })
|
||||
local c = DeepCharm.reactive({ value = 0 })
|
||||
local target: unknown, payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a, b = b, c = c })
|
||||
server.connect(function(...)
|
||||
target, payloads = ...
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||
assert(payloads[1].data.a.value == 0, "Expected init payload to contain atom 'a'")
|
||||
assert(payloads[1].data.b.value == 0, "Expected init payload to contain atom 'b'")
|
||||
assert(payloads[1].data.c.value == 0, "Expected init payload to contain atom 'c'")
|
||||
|
||||
a.value = 1
|
||||
a.value = 2
|
||||
b.value = 2
|
||||
server.flush()
|
||||
assert(target == client, "Expected target to be the client")
|
||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||
assert(payloads[1].data.a.value == 1, "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[2].data.a.value == 2, "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[3].data.b.value == 2, "Expected patch payload to contain atom 'b'")
|
||||
end)
|
||||
|
||||
test("should init new clients", function()
|
||||
local clientA = {}
|
||||
local clientB = {}
|
||||
local a = Charm.atom(0)
|
||||
local payloadsA: { types.SyncPayload }, payloadsB: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(clientA, { a = a })
|
||||
server.connect(function(target, payloads)
|
||||
if target == clientA then
|
||||
payloadsA = payloads
|
||||
elseif target == clientB then
|
||||
payloadsB = payloads
|
||||
end
|
||||
end)
|
||||
|
||||
a(1)
|
||||
a(2)
|
||||
server.flush()
|
||||
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
|
||||
assert(payloadsA[1].data.a == 1, "Expected init payload to contain atom 'a' for client A")
|
||||
assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A")
|
||||
assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A")
|
||||
|
||||
server.addSignalsToClient(clientB, { a = a })
|
||||
a(3)
|
||||
a(4)
|
||||
server.flush()
|
||||
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
|
||||
assert(payloadsA[1].data.a == 3, "Expected patch payload to contain atom 'a' for client A")
|
||||
assert(payloadsA[2].data.a == 4, "Expected patch payload to contain atom 'a' for client A")
|
||||
assert(payloadsB[1].type == "init", "Expected first payload to be init for client B")
|
||||
assert(payloadsB[1].data.a == 3, "Expected init payload to contain atom 'a' for client B")
|
||||
assert(payloadsB[2].type == "patch", "Expected second payload to be patch for client B")
|
||||
assert(payloadsB[2].data.a == 4, "Expected second patch payload to contain atom 'a' with value 3 for client B")
|
||||
end)
|
||||
|
||||
test("should init new atoms", function()
|
||||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local b = Charm.atom(0)
|
||||
local payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(_, ...)
|
||||
payloads = ...
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
|
||||
|
||||
server.addSignalsToClient(client, { b = b })
|
||||
a(1)
|
||||
a(2)
|
||||
b(1)
|
||||
b(2)
|
||||
server.flush()
|
||||
assert(payloads[1].type == "patch", "Expected first payload to be patch")
|
||||
assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'")
|
||||
assert(payloads[3].type == "init", "Expected third payload to be init")
|
||||
assert(payloads[3].data.b == 1, "Expected init payload to contain atom 'b'")
|
||||
assert(payloads[4].type == "patch", "Expected fourth payload to be patch")
|
||||
assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'")
|
||||
end)
|
||||
|
||||
test("should not send unchanged state", function()
|
||||
local client = {}
|
||||
local a = Charm.atom(0)
|
||||
local payloads: { types.SyncPayload }
|
||||
|
||||
server.addSignalsToClient(client, { a = a })
|
||||
server.connect(function(_, p)
|
||||
payloads = p
|
||||
end)
|
||||
|
||||
server.flush()
|
||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
||||
assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'")
|
||||
|
||||
payloads = {}
|
||||
a(0)
|
||||
server.flush()
|
||||
assert(#payloads == 0, "Expected no payloads to be sent for unchanged state")
|
||||
end)
|
||||
end)
|
||||
|
||||
return {}
|
||||
22
packages/charm-sync/test/utils.test.luau
Normal file
22
packages/charm-sync/test/utils.test.luau
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
local Charm = require("../Charm")
|
||||
local test = require("../../../tests/test")
|
||||
local utils = require("../src/utils")
|
||||
|
||||
test("should flatten functions", function()
|
||||
local input = {
|
||||
a = Charm.atom(0),
|
||||
b = { c = Charm.atom(0) },
|
||||
d = { e = { f = utils.signalToAtom(Charm.signal(0)) } },
|
||||
}
|
||||
|
||||
local result = utils.flatten<<{ [string]: Charm.Atom<number> }>>(input)
|
||||
|
||||
assert(result.a == input.a, "should flatten the first level")
|
||||
assert(result["b/c"] == input.b.c, "should flatten the second level")
|
||||
assert(result["d/e/f"] == input.d.e.f, "should flatten the third level")
|
||||
|
||||
assert(result.b == nil, "should remove the original nested tables")
|
||||
assert(result.d == nil, "should remove the original nested tables")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
[package]
|
||||
name = "littensy/charm-sync"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0-rc.7"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
realm = "shared"
|
||||
exclude = [
|
||||
"node_modules",
|
||||
"test",
|
||||
"src/index.d.ts",
|
||||
"package.json",
|
||||
"Charm.luau",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
charm = "littensy/charm@^0.10"
|
||||
Charm = "littensy/charm@^0.11.0-rc.6"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue