mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
refactor: simplify charm sync validation
This commit is contained in:
parent
7bb6edb403
commit
dffeb35933
7 changed files with 48 additions and 74 deletions
12
README.md
12
README.md
|
|
@ -641,12 +641,12 @@ end)
|
||||||
|
|
||||||
A configuration table that customizes the behavior of Charm Sync on the server.
|
A configuration table that customizes the behavior of Charm Sync on the server.
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| --------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| interval | `0` | The frequency at which the server will send patches to the client, in seconds. A value of `0` sends updates on the next frame. Set to a negative value to disable the interval. |
|
| interval | `0` | The frequency at which the server will send patches to the client, in seconds. A value of `0` sends updates on the next frame. Set to a negative value to disable the interval. |
|
||||||
| preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. |
|
| preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. |
|
||||||
| fixArrays | `true` | When `true`, Charm will attempt to work around Roblox remote event limitations regarding array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). |
|
| fixArrays | `true` | When `true`, Charm will attempt to work around Roblox remote event limitations regarding array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). |
|
||||||
| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, patches will be validated to enforce remote event limitations. |
|
| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, synced values containing unsafe sparse arrays or mixed tables will throw an error. See the [remote argument limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,16 +81,18 @@ test("preserves array density after serdes", function()
|
||||||
local patches = mockArraySerdes(patch.diff(prevState, nextState))
|
local patches = mockArraySerdes(patch.diff(prevState, nextState))
|
||||||
local applied = patch.apply(prevState, patches)
|
local applied = patch.apply(prevState, patches)
|
||||||
|
|
||||||
for index in next, prevState do
|
for index in prevState do
|
||||||
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
|
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
|
||||||
end
|
end
|
||||||
|
|
||||||
for index in next, applied do
|
for index in applied do
|
||||||
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
|
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("cannot preserve sparse arrays", function()
|
test("cannot preserve sparse arrays", function()
|
||||||
|
config.validatePatches = false
|
||||||
|
|
||||||
local prevState = {}
|
local prevState = {}
|
||||||
local nextState = { [2] = "foo" }
|
local nextState = { [2] = "foo" }
|
||||||
|
|
||||||
|
|
@ -106,13 +108,13 @@ end)
|
||||||
test("optionally fixes sparse arrays", function()
|
test("optionally fixes sparse arrays", function()
|
||||||
config.fixArrays = true
|
config.fixArrays = true
|
||||||
|
|
||||||
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
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`)
|
assert(type(index) == "string", `expected {type(index)} {index} to be string`)
|
||||||
end
|
end
|
||||||
|
|
||||||
config.fixArrays = false
|
config.fixArrays = false
|
||||||
|
|
||||||
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
|
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`)
|
assert(type(index) == "number", `expected {type(index)} {index} to be number`)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
@ -120,14 +122,14 @@ end)
|
||||||
test("optionally validates state", function()
|
test("optionally validates state", function()
|
||||||
local success = pcall(function()
|
local success = pcall(function()
|
||||||
config.validatePatches = true
|
config.validatePatches = true
|
||||||
patch.diff({}, { [0.1] = true })
|
patch.diff({}, { [1] = 1, a = true })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert(not success, "should not allow non-integer keys")
|
assert(not success, "should not allow mixed tables")
|
||||||
|
|
||||||
success = pcall(function()
|
success = pcall(function()
|
||||||
config.validatePatches = false
|
config.validatePatches = false
|
||||||
patch.diff({}, { [0.1] = true })
|
patch.diff({}, { [1] = 1, a = true })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert(success, "should skip validation if not requested")
|
assert(success, "should skip validation if not requested")
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,18 @@
|
||||||
local Charm = require("../../Charm")
|
|
||||||
local suite = require("../../../../test/suite")
|
local suite = require("../../../../test/suite")
|
||||||
local validate = require("../validate")
|
local validate = require("../validate")
|
||||||
|
|
||||||
local atom = Charm.atom
|
|
||||||
local test = suite.test
|
local test = suite.test
|
||||||
|
|
||||||
test("validates inputs", function()
|
test("validates inputs", function()
|
||||||
local cases: { [string]: { value: any, expected: any } } = {
|
local cases: { [string]: { value: any, expected: any } } = {
|
||||||
array = { value = { 1, 2, 3 }, expected = true },
|
array = { value = { 1, 2, 3 }, expected = true },
|
||||||
array_sparse = { value = { 1, nil :: any, 3 }, expected = true },
|
arraySparse = { value = { 1, nil :: any, 3 }, expected = true },
|
||||||
array_sparse_invalid = { value = { [1] = 0, [3] = 0 }, expected = false },
|
arraySparseInvalid = { value = { [1] = 0, [3] = 0 }, expected = false },
|
||||||
|
mixedTable = { value = { [1] = "foo", a = true }, expected = false },
|
||||||
map_decimal_key = { value = { [1.5] = 0 }, expected = false },
|
|
||||||
map_infinity_key = { value = { [math.huge] = true }, expected = false },
|
|
||||||
map_mixed_key = { value = { [1] = 0, a = 0 }, expected = false },
|
|
||||||
|
|
||||||
nested_atom = { value = atom(0), expected = false },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, case in next, cases do
|
for key, case in cases do
|
||||||
local success, result = pcall(function()
|
local success, result: any = pcall(validate, case.value)
|
||||||
validate(case.value, key)
|
|
||||||
end)
|
|
||||||
|
|
||||||
if case.expected and not success then
|
if case.expected and not success then
|
||||||
error(`Expected {key} to be valid, threw {result}`)
|
error(`Expected {key} to be valid, threw {result}`)
|
||||||
elseif not case.expected and success then
|
elseif not case.expected and success then
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ return {
|
||||||
--- This option should be disabled if your network library uses a custom
|
--- This option should be disabled if your network library uses a custom
|
||||||
--- serialization method (i.e. Zap, ByteNet) to prevent interference.
|
--- serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||||
fixArrays = true,
|
fixArrays = true,
|
||||||
--- When `true`, patches will be validated before being sent to clients to
|
--- When `true`, synced state containing unsafe sparse arrays or mixed
|
||||||
--- enforce remote event limitations. Only checked in strict mode and if
|
--- tables will throw an error. Only checked in strict mode and if
|
||||||
--- `fixArrays` is enabled. Defaults to `true`.
|
--- `fixArrays` is enabled. Defaults to `true`.
|
||||||
validatePatches = true,
|
validatePatches = true,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
packages/charm-sync/src/index.d.ts
vendored
4
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -90,8 +90,8 @@ export const config: {
|
||||||
*/
|
*/
|
||||||
fixArrays: boolean;
|
fixArrays: boolean;
|
||||||
/**
|
/**
|
||||||
* When `true`, patches will be validated before being sent to clients to
|
* When `true`, synced state containing unsafe sparse arrays or mixed
|
||||||
* enforce remote event limitations. Only checked in strict mode and if
|
* tables will throw an error. Only checked in strict mode and if
|
||||||
* `fixArrays` is enabled. Defaults to `true`.
|
* `fixArrays` is enabled. Defaults to `true`.
|
||||||
*
|
*
|
||||||
* @server
|
* @server
|
||||||
|
|
|
||||||
|
|
@ -74,15 +74,13 @@ local function diff(currentState: any, nextState: any): any
|
||||||
end
|
end
|
||||||
|
|
||||||
if config.fixArrays then
|
if config.fixArrays then
|
||||||
-- Coerce sparse array patches into dictionaries
|
-- Convert sparse array patches that could be misserialized
|
||||||
if currentState[1] ~= nil or nextState[1] ~= nil then
|
if currentState[1] ~= nil or nextState[1] ~= nil then
|
||||||
patches = stringifySparseArray(patches)
|
patches = stringifySparseArray(patches)
|
||||||
end
|
end
|
||||||
|
|
||||||
if globals.strict and config.validatePatches then
|
if globals.strict and config.validatePatches then
|
||||||
for key, value in patches do
|
validate(patches)
|
||||||
validate(value, key)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,50 @@
|
||||||
local SAFE_KEYS = { string = true, number = true }
|
|
||||||
local UNSAFE_VALUES = { ["function"] = true, thread = true }
|
|
||||||
|
|
||||||
local function isTableUnsafe(object: { [any]: any })
|
local function isTableUnsafe(object: { [any]: any })
|
||||||
local key = next(object)
|
local key = next(object)
|
||||||
|
|
||||||
if key == nil then
|
if key == nil then
|
||||||
-- An empty table is always safe
|
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
local keyType = type(key)
|
local keyType = type(key)
|
||||||
local keyCount = 0
|
local maxKey = 0
|
||||||
|
|
||||||
-- Keys must either be all strings or all numbers
|
-- Keys must all be the same type
|
||||||
for key in object do
|
for key in object do
|
||||||
if type(key) ~= keyType then
|
if type(key) ~= keyType then
|
||||||
return true
|
return true
|
||||||
|
elseif keyType == "number" and key > maxKey then
|
||||||
|
maxKey = key
|
||||||
end
|
end
|
||||||
keyCount += 1
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Array keys must be sequential
|
-- Array keys must be sequential
|
||||||
if keyType == "number" and keyCount > #object then
|
if keyType == "number" and maxKey > #object then
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Validates a value to ensure it can be synced over a remote event.
|
--- Throws an error 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 value The table value.
|
||||||
--- @param key The key of the value in the table.
|
--- @param key The key of the value.
|
||||||
--- @error Throws an error if the value cannot be synced.
|
--- @error Throws an error if the value cannot be synced.
|
||||||
local function validate(value: any, key: any)
|
local function validate(value: { [any]: any })
|
||||||
local typeKey = type(key)
|
if isTableUnsafe(value) then
|
||||||
local typeValue = type(value)
|
-- Silence warnings during testing
|
||||||
|
if game then
|
||||||
if not SAFE_KEYS[typeKey] then
|
warn("Cannot sync table:", value)
|
||||||
error(`Invalid key type '{typeKey}' at key '{key}'`)
|
|
||||||
elseif UNSAFE_VALUES[typeValue] then
|
|
||||||
error(`Invalid value type '{typeValue}' at key '{key}'`)
|
|
||||||
elseif typeValue == "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 table was found at 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 typeKey == "number" then
|
|
||||||
if key == math.huge or key == -math.huge then
|
|
||||||
error("Cannot sync infinity as key")
|
|
||||||
elseif key ~= math.floor(key) then
|
|
||||||
error("Cannot sync non-integer number as key")
|
|
||||||
end
|
end
|
||||||
|
error(
|
||||||
|
`Cannot sync tables unsupported by remote events! See the warning above for more info.\n\n`
|
||||||
|
.. "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"
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue