@colyseus/schema
Advanced tools
Comparing version 0.3.2 to 0.3.3
@@ -1,2 +0,2 @@ | ||
import * as decode from "./msgpack/decode"; | ||
import * as decode from "./encoding/decode"; | ||
import { ArraySchema } from './types/ArraySchema'; | ||
@@ -13,2 +13,6 @@ /** | ||
}; | ||
export declare type FilterCallback = (this: Schema, client: Client, instance: Schema, root?: Schema) => boolean; | ||
export declare type Client = { | ||
sessionId: string; | ||
} & any; | ||
export interface DataChange<T = any> { | ||
@@ -27,2 +31,6 @@ field: string; | ||
}; | ||
static _filters: { | ||
[field: string]: FilterCallback; | ||
}; | ||
static _descriptors: PropertyDescriptorMap & ThisType<any>; | ||
$changed: boolean; | ||
@@ -41,10 +49,17 @@ protected $allChanges: { | ||
constructor(...args: any[]); | ||
markAsChanged(field: string, value?: Schema | any): void; | ||
readonly _schema: Definition; | ||
readonly _descriptors: PropertyDescriptorMap & ThisType<any>; | ||
readonly _indexes: { | ||
[field: string]: number; | ||
}; | ||
readonly _filters: { | ||
[field: string]: FilterCallback; | ||
}; | ||
markAsChanged(field: string, value?: Schema | any): void; | ||
markAsUnchanged(): void; | ||
decode(bytes: any, it?: decode.Iterator): this; | ||
encode(root?: boolean, encodeAll?: boolean): any[]; | ||
encode(root?: Schema, encodeAll?: boolean, client?: Client): any[]; | ||
encodeFiltered(client: Client): any[]; | ||
encodeAll(): any[]; | ||
encodeAllFiltered(client: Client): any[]; | ||
toJSON(): {}; | ||
@@ -67,7 +82,11 @@ } | ||
static encode(instance: Schema): any[]; | ||
static decode(bytes: number[]): any; | ||
static decode(bytes: number[]): Schema; | ||
} | ||
/** | ||
* Decorators / Proxies | ||
* `@type()` decorator for proxies | ||
*/ | ||
export declare function type(type: DefinitionType): (target: any, field: string) => void; | ||
export declare function type(type: DefinitionType): PropertyDecorator; | ||
/** | ||
* `@filter()` decorator for defining data filters per client | ||
*/ | ||
export declare function filter(cb: FilterCallback): PropertyDecorator; |
@@ -23,4 +23,4 @@ "use strict"; | ||
var spec_1 = require("./spec"); | ||
var encode = require("./msgpack/encode"); | ||
var decode = require("./msgpack/decode"); | ||
var encode = require("./encoding/encode"); | ||
var decode = require("./encoding/decode"); | ||
var ArraySchema_1 = require("./types/ArraySchema"); | ||
@@ -57,6 +57,36 @@ var MapSchema_1 = require("./types/MapSchema"); | ||
} | ||
this.$changed = false; | ||
this.$allChanges = {}; | ||
this.$changes = {}; | ||
// fix enumerability of fields for end-user | ||
Object.defineProperties(this, { | ||
$changed: { value: false, enumerable: false, writable: true }, | ||
$changes: { value: {}, enumerable: false, writable: true }, | ||
$allChanges: { value: {}, enumerable: false, writable: true }, | ||
$parent: { value: undefined, enumerable: false, writable: true }, | ||
$parentField: { value: undefined, enumerable: false, writable: true }, | ||
$parentIndexChange: { value: undefined, enumerable: false, writable: true }, | ||
}); | ||
var descriptors = this._descriptors; | ||
if (descriptors) { | ||
Object.defineProperties(this, descriptors); | ||
} | ||
} | ||
Object.defineProperty(Schema.prototype, "_schema", { | ||
get: function () { return this.constructor._schema; }, | ||
enumerable: true, | ||
configurable: true | ||
}); | ||
Object.defineProperty(Schema.prototype, "_descriptors", { | ||
get: function () { return this.constructor._descriptors; }, | ||
enumerable: true, | ||
configurable: true | ||
}); | ||
Object.defineProperty(Schema.prototype, "_indexes", { | ||
get: function () { return this.constructor._indexes; }, | ||
enumerable: true, | ||
configurable: true | ||
}); | ||
Object.defineProperty(Schema.prototype, "_filters", { | ||
get: function () { return this.constructor._filters; }, | ||
enumerable: true, | ||
configurable: true | ||
}); | ||
Schema.prototype.markAsChanged = function (field, value) { | ||
@@ -66,5 +96,6 @@ var fieldSchema = this._schema[field]; | ||
if (value !== undefined) { | ||
if (Array.isArray(value.$parentField) || | ||
if (value && | ||
Array.isArray(value.$parentField) || | ||
fieldSchema && (Array.isArray(fieldSchema) || fieldSchema.map)) { | ||
var $parentField = value.$parentField || []; | ||
var $parentField = value && value.$parentField || []; | ||
// used for MAP/ARRAY | ||
@@ -93,3 +124,3 @@ var fieldName = ($parentField.length > 0) | ||
} | ||
else if (value.$parentField) { | ||
else if (value && value.$parentField) { | ||
// used for direct type relationship | ||
@@ -109,16 +140,40 @@ this.$changes[value.$parentField] = value; | ||
}; | ||
Object.defineProperty(Schema.prototype, "_schema", { | ||
get: function () { | ||
return this.constructor._schema; | ||
}, | ||
enumerable: true, | ||
configurable: true | ||
}); | ||
Object.defineProperty(Schema.prototype, "_indexes", { | ||
get: function () { | ||
return this.constructor._indexes; | ||
}, | ||
enumerable: true, | ||
configurable: true | ||
}); | ||
Schema.prototype.markAsUnchanged = function () { | ||
var schema = this._schema; | ||
var changes = this.$changes; | ||
for (var field in changes) { | ||
var type_1 = schema[field]; | ||
var value = changes[field]; | ||
// skip unchagned fields | ||
if (value === undefined) { | ||
continue; | ||
} | ||
if (type_1._schema) { | ||
value.markAsUnchanged(); | ||
} | ||
else if (Array.isArray(type_1)) { | ||
// encode Array of type | ||
for (var i = 0, l = value.length; i < l; i++) { | ||
var index = value[i]; | ||
var item = this["_" + field][index]; | ||
if (typeof (type_1[0]) !== "string") { // is array of Schema | ||
item.markAsUnchanged(); | ||
} | ||
} | ||
} | ||
else if (type_1.map) { | ||
var keys = value; | ||
var mapKeys = Object.keys(this["_" + field]); | ||
for (var i = 0; i < keys.length; i++) { | ||
var key = mapKeys[keys[i]] || keys[i]; | ||
var item = this["_" + field][key]; | ||
if (item instanceof Schema) { | ||
item.markAsUnchanged(); | ||
} | ||
} | ||
} | ||
} | ||
this.$changed = false; | ||
this.$changes = {}; | ||
}; | ||
Schema.prototype.decode = function (bytes, it) { | ||
@@ -137,18 +192,24 @@ if (it === void 0) { it = { offset: 0 }; } | ||
var index = bytes[it.offset++]; | ||
var field = fieldsByIndex[index]; | ||
if (index === spec_1.END_OF_STRUCTURE) { | ||
return "break"; | ||
} | ||
var type_1 = schema[field]; | ||
var field = fieldsByIndex[index]; | ||
var type_2 = schema[field]; | ||
var value = void 0; | ||
var change = void 0; // for triggering onChange | ||
var hasChange = false; | ||
if (type_1._schema) { | ||
value = this_1["_" + field] || new type_1(); | ||
value.$parent = this_1; | ||
value.decode(bytes, it); | ||
if (type_2._schema) { | ||
if (decode.nilCheck(bytes, it)) { | ||
it.offset++; | ||
value = null; | ||
} | ||
else { | ||
value = this_1["_" + field] || new type_2(); | ||
value.$parent = this_1; | ||
value.decode(bytes, it); | ||
} | ||
hasChange = true; | ||
} | ||
else if (Array.isArray(type_1)) { | ||
type_1 = type_1[0]; | ||
else if (Array.isArray(type_2)) { | ||
type_2 = type_2[0]; | ||
change = []; | ||
@@ -183,7 +244,7 @@ var valueRef_1 = this_1["_" + field] || new ArraySchema_1.ArraySchema(); | ||
} | ||
if (type_1.prototype instanceof Schema) { | ||
if (type_2.prototype instanceof Schema) { | ||
var item = void 0; | ||
var isNew = (hasIndexChange && indexChangedFrom === undefined && newIndex !== undefined); | ||
if (isNew) { | ||
item = new type_1(); | ||
item = new type_2(); | ||
} | ||
@@ -197,3 +258,3 @@ else if (indexChangedFrom !== undefined) { | ||
if (!item) { | ||
item = new type_1(); | ||
item = new type_2(); | ||
isNew = true; | ||
@@ -216,3 +277,3 @@ } | ||
else { | ||
value[newIndex] = decodePrimitiveType(type_1, bytes, it); | ||
value[newIndex] = decodePrimitiveType(type_2, bytes, it); | ||
} | ||
@@ -222,4 +283,4 @@ change.push(value[newIndex]); | ||
} | ||
else if (type_1.map) { | ||
type_1 = type_1.map; | ||
else if (type_2.map) { | ||
type_2 = type_2.map; | ||
var valueRef = this_1["_" + field] || new MapSchema_1.MapSchema(); | ||
@@ -254,3 +315,3 @@ value = valueRef.clone(); | ||
if (hasIndexChange && previousKey === undefined && hasMapIndex) { | ||
item = new type_1(); | ||
item = new type_2(); | ||
} | ||
@@ -263,4 +324,4 @@ else if (previousKey !== undefined) { | ||
} | ||
if (!item && type_1 !== "string") { | ||
item = new type_1(); | ||
if (!item && type_2 !== "string") { | ||
item = new type_2(); | ||
isNew = true; | ||
@@ -279,4 +340,4 @@ } | ||
} | ||
else if (type_1 === "string") { | ||
value[newKey] = decodePrimitiveType(type_1, bytes, it); | ||
else if (type_2 === "string") { | ||
value[newKey] = decodePrimitiveType(type_2, bytes, it); | ||
} | ||
@@ -297,3 +358,3 @@ else { | ||
else { | ||
value = decodePrimitiveType(type_1, bytes, it); | ||
value = decodePrimitiveType(type_2, bytes, it); | ||
hasChange = true; | ||
@@ -321,8 +382,9 @@ } | ||
}; | ||
Schema.prototype.encode = function (root, encodeAll) { | ||
if (root === void 0) { root = true; } | ||
Schema.prototype.encode = function (root, encodeAll, client) { | ||
var _this = this; | ||
if (root === void 0) { root = this; } | ||
if (encodeAll === void 0) { encodeAll = false; } | ||
var encodedBytes = []; | ||
var endStructure = function () { | ||
if (!root) { | ||
if (_this !== root) { | ||
encodedBytes.push(spec_1.END_OF_STRUCTURE); | ||
@@ -338,2 +400,3 @@ } | ||
var indexes = this._indexes; | ||
var filters = this._filters; | ||
var changes = (encodeAll) | ||
@@ -344,4 +407,5 @@ ? this.$allChanges | ||
var bytes = []; | ||
var type_2 = schema[field]; | ||
var value = changes[field]; | ||
var type_3 = schema[field]; | ||
var filter_1 = (filters && filters[field]); | ||
var value = (filter_1 && this.$allChanges[field]) || changes[field]; | ||
var fieldIndex = indexes[field]; | ||
@@ -352,14 +416,26 @@ // skip unchagned fields | ||
} | ||
if (type_2._schema) { | ||
if (type_3._schema) { | ||
if (client && filter_1) { | ||
// skip if not allowed by custom filter | ||
if (!filter_1.call(this, client, value, root)) { | ||
continue; | ||
} | ||
} | ||
encode.number(bytes, fieldIndex); | ||
// encode child object | ||
bytes = bytes.concat(value.encode(false, encodeAll)); | ||
// ensure parent is set | ||
// in case it was manually instantiated | ||
if (!value.$parent) { | ||
value.$parent = this; | ||
value.$parentField = field; | ||
if (value) { | ||
bytes = bytes.concat(value.encode(root, encodeAll, client)); | ||
// ensure parent is set | ||
// in case it was manually instantiated | ||
if (!value.$parent) { | ||
value.$parent = this; | ||
value.$parentField = field; | ||
} | ||
} | ||
else { | ||
// value has been removed | ||
encode.uint8(bytes, spec_1.NIL); | ||
} | ||
} | ||
else if (Array.isArray(type_2)) { | ||
else if (Array.isArray(type_3)) { | ||
encode.number(bytes, fieldIndex); | ||
@@ -374,3 +450,9 @@ // total of items in the array | ||
var item = this["_" + field][index]; | ||
if (typeof (type_2[0]) !== "string") { // is array of Schema | ||
if (client && filter_1) { | ||
// skip if not allowed by custom filter | ||
if (!filter_1.call(this, client, item, root)) { | ||
continue; | ||
} | ||
} | ||
if (typeof (type_3[0]) !== "string") { // is array of Schema | ||
encode.number(bytes, index); | ||
@@ -390,7 +472,7 @@ if (item === undefined) { | ||
} | ||
bytes = bytes.concat(item.encode(false, encodeAll)); | ||
bytes = bytes.concat(item.encode(root, encodeAll, client)); | ||
} | ||
else { | ||
encode.number(bytes, i); | ||
if (!encodePrimitiveType(type_2[0], bytes, index)) { | ||
if (!encodePrimitiveType(type_3[0], bytes, index)) { | ||
console.log("cannot encode", schema[field]); | ||
@@ -402,3 +484,3 @@ continue; | ||
} | ||
else if (type_2.map) { | ||
else if (type_3.map) { | ||
// encode Map of type | ||
@@ -413,2 +495,8 @@ encode.number(bytes, fieldIndex); | ||
var mapItemIndex = this["_" + field]._indexes[key]; | ||
if (client && filter_1) { | ||
// skip if not allowed by custom filter | ||
if (!filter_1.call(this, client, item, root)) { | ||
continue; | ||
} | ||
} | ||
if (encodeAll) { | ||
@@ -435,6 +523,6 @@ if (item) { | ||
encode.string(bytes, key); | ||
var mapKey = mapKeys.indexOf(key); | ||
if (mapKey >= 0) { | ||
this["_" + field]._indexes[key] = mapKey; | ||
} | ||
// const mapKey = mapKeys.indexOf(key); | ||
// if (!client && mapKey >= 0) { | ||
// this[`_${field}`]._indexes[key] = mapKey; | ||
// } | ||
} | ||
@@ -444,6 +532,6 @@ if (item instanceof Schema) { | ||
item.$parentField = [field, keys[i]]; | ||
bytes = bytes.concat(item.encode(false, encodeAll)); | ||
bytes = bytes.concat(item.encode(root, encodeAll, client)); | ||
} | ||
else if (item !== undefined) { | ||
encodePrimitiveType(type_2.map, bytes, item); | ||
encodePrimitiveType(type_3.map, bytes, item); | ||
} | ||
@@ -454,7 +542,16 @@ else { | ||
} | ||
this["_" + field]._updateIndexes(); | ||
// TODO: track array/map indexes per client? | ||
if (!client) { | ||
this["_" + field]._updateIndexes(); | ||
} | ||
} | ||
else { | ||
if (client && filter_1) { | ||
// skip if not allowed by custom filter | ||
if (!filter_1.call(this, client, value, root)) { | ||
continue; | ||
} | ||
} | ||
encode.number(bytes, fieldIndex); | ||
if (!encodePrimitiveType(type_2, bytes, value)) { | ||
if (!encodePrimitiveType(type_3, bytes, value)) { | ||
console.log("cannot encode", schema[field]); | ||
@@ -468,9 +565,17 @@ continue; | ||
endStructure(); | ||
this.$changed = false; | ||
this.$changes = {}; | ||
if (!client) { | ||
this.$changed = false; | ||
this.$changes = {}; | ||
} | ||
return encodedBytes; | ||
}; | ||
Schema.prototype.encodeFiltered = function (client) { | ||
return this.encode(this, false, client); | ||
}; | ||
Schema.prototype.encodeAll = function () { | ||
return this.encode(true, true); | ||
return this.encode(this, true); | ||
}; | ||
Schema.prototype.encodeAllFiltered = function (client) { | ||
return this.encode(this, true, client); | ||
}; | ||
Schema.prototype.toJSON = function () { | ||
@@ -608,3 +713,4 @@ var schema = this._schema; | ||
}); | ||
var rootType = new schemaTypes[0]; | ||
var rootType = schemaTypes[0]; | ||
var rootInstance = new rootType(); | ||
/** | ||
@@ -620,3 +726,3 @@ * auto-initialize referenced types on root type | ||
var isMap = !isArray && fieldType.map; | ||
rootType[fieldName] = (isArray) | ||
rootInstance[fieldName] = (isArray) | ||
? new ArraySchema_1.ArraySchema() | ||
@@ -630,3 +736,3 @@ : (isMap) | ||
} | ||
return rootType; | ||
return rootInstance; | ||
}; | ||
@@ -640,3 +746,3 @@ __decorate([ | ||
/** | ||
* Decorators / Proxies | ||
* `@type()` decorator for proxies | ||
*/ | ||
@@ -652,2 +758,3 @@ function type(type) { | ||
constructor._indexes = {}; | ||
constructor._descriptors = {}; | ||
} | ||
@@ -663,8 +770,8 @@ constructor._indexes[field] = Object.keys(constructor._schema).length; | ||
var fieldCached = "_" + field; | ||
Object.defineProperty(target, fieldCached, { | ||
constructor._descriptors[fieldCached] = { | ||
enumerable: false, | ||
configurable: false, | ||
writable: true, | ||
}); | ||
Object.defineProperty(target, field, { | ||
}; | ||
constructor._descriptors[field] = { | ||
get: function () { | ||
@@ -759,4 +866,7 @@ return this[fieldCached]; | ||
// directly assigning a `Schema` object | ||
value.$parent = this; | ||
value.$parentField = field; | ||
// value may be set to null | ||
if (value) { | ||
value.$parent = this; | ||
value.$parentField = field; | ||
} | ||
this.markAsChanged(field, value); | ||
@@ -770,6 +880,22 @@ } | ||
enumerable: true, | ||
configurable: false | ||
}); | ||
configurable: true | ||
}; | ||
}; | ||
} | ||
exports.type = type; | ||
/** | ||
* `@filter()` decorator for defining data filters per client | ||
*/ | ||
function filter(cb) { | ||
return function (target, field) { | ||
var constructor = target.constructor; | ||
/* | ||
* static filters | ||
*/ | ||
if (!constructor._filters) { | ||
constructor._filters = {}; | ||
} | ||
constructor._filters[field] = cb; | ||
}; | ||
} | ||
exports.filter = filter; |
export { MapSchema } from "./types/MapSchema"; | ||
export { ArraySchema } from "./types/ArraySchema"; | ||
export { Schema, type, DataChange, PrimitiveType, Definition, DefinitionType, Reflection, ReflectionType, ReflectionField, } from "./annotations"; | ||
export { Schema, type, filter, DataChange, PrimitiveType, Definition, DefinitionType, FilterCallback, Reflection, ReflectionType, ReflectionField, } from "./annotations"; |
@@ -11,2 +11,3 @@ "use strict"; | ||
exports.type = annotations_1.type; | ||
exports.filter = annotations_1.filter; | ||
// Reflection | ||
@@ -13,0 +14,0 @@ exports.Reflection = annotations_1.Reflection; |
@@ -23,3 +23,2 @@ "use strict"; | ||
currentClass = new Class(); | ||
; | ||
classes.push(currentClass); | ||
@@ -30,2 +29,6 @@ break; | ||
case ts.SyntaxKind.Identifier: | ||
// console.log("NODE =>", node.getText()); | ||
if (node.getText() === "type" && node.parent.kind !== ts.SyntaxKind.ImportSpecifier) { | ||
console.log("TYPE DECORATORS:", node.parent.parent.parent.decorators[0].getText()); | ||
} | ||
if (node.parent.kind === ts.SyntaxKind.ClassDeclaration) { | ||
@@ -32,0 +35,0 @@ currentClass.name = node.getText(); |
{ | ||
"name": "@colyseus/schema", | ||
"version": "0.3.2", | ||
"version": "0.3.3", | ||
"description": "Schema-based binary serializer / de-serializer. ", | ||
"main": "lib/index.js", | ||
"types": "lib/index.d.ts", | ||
"bin": { | ||
"stateify": "./bin/stateify" | ||
}, | ||
"scripts": { | ||
"test": "mocha --require ts-node/register test/**Test.ts", | ||
"watch": "tsc -w", | ||
"prepublish": "tsc" | ||
@@ -10,0 +14,0 @@ }, |
115
README.md
@@ -19,15 +19,2 @@ <div align="center"> | ||
> WORK-IN-PROGRESS EXPERIMENT OF A NEW SERIALIZATION ALGORITHM FOR [COLYSEUS](https://github.com/gamestdio/colyseus) | ||
Initial thoghts/assumptions: | ||
- no bottleneck to detect state changes. | ||
- have a schema definition on both server and client | ||
- better experience on staticaly-typed languages (C#, C++) | ||
- mutations should be cheap. | ||
Practical Colyseus issues this should solve: | ||
- Avoid decoding large objects that haven't been patched | ||
- Allow to send different patches for each client | ||
- Better developer experience on statically-typed languages | ||
## Defining Schema | ||
@@ -110,4 +97,11 @@ | ||
#### Array of a primitive type | ||
#### Array of custom `Schema` type | ||
```typescript | ||
@type([ Player ]) | ||
arrayOfPlayers: ArraySchema<Player>; | ||
``` | ||
#### Array of a primitive type (**not currently supported!**) | ||
You can't mix types inside arrays. | ||
@@ -123,10 +117,10 @@ | ||
#### Array of custom `Schema` type | ||
#### Map of custom `Schema` type | ||
```typescript | ||
@type([ Player ]) | ||
arrayOfPlayers: ArraySchema<Player>; | ||
@type({ map: Player }) | ||
mapOfPlayers: MapSchema<Player>; | ||
``` | ||
#### Map of a primitive type | ||
#### Map of a primitive type (**not currently supported!**) | ||
@@ -143,7 +137,24 @@ You can't mix types inside maps. | ||
#### Map of custom `Schema` type | ||
### Data filters (experimental) | ||
When using with [Colyseus 0.10](https://github.com/colyseus/colyseus), you may provide a `@filter` per field, to filter out what you don't want to serialize for a specific client. | ||
On the example below, we are filtering entities which are close to the player entity. | ||
```typescript | ||
@type({ map: Player }) | ||
mapOfPlayers: MapSchema<Player>; | ||
import { Schema, type, filter } from "@colyseus/schema"; | ||
export class State extends Schema { | ||
@filter(function(this: State, client: any, value: Entity) { | ||
const currentPlayer = this.entities[client.sessionId] | ||
var a = value.x - currentPlayer.x; | ||
var b = value.y - currentPlayer.y; | ||
return (Math.sqrt(a * a + b * b)) <= 10; | ||
}) | ||
@type({ map: Entity }) | ||
entities = new MapSchema<Entity>(); | ||
} | ||
``` | ||
@@ -203,51 +214,2 @@ | ||
## Aimed usage on Colyseus | ||
This is the ideal scenario that should be possible to achieve. | ||
### Customizing which data each client will receive | ||
```typescript | ||
class MyRoom extends Room<State> { | ||
onInit() { | ||
this.setState(new State()); | ||
} | ||
onPatch (client: Client, state: State) { | ||
const player = state.players[client.sessionId]; | ||
// filter enemies closer to current player | ||
state.enemies = state.enemies.filter(enemy => | ||
distance(enemy.x, enemy.y, player.x, player.y) < 50); | ||
return state; | ||
} | ||
} | ||
``` | ||
### Broadcasting different patches for each client | ||
```typescript | ||
class Room<T> { | ||
// ... | ||
public onPatch?(client: Client, state: T); | ||
// ... | ||
broadcastPatch() { | ||
if (this.onPatch) { | ||
for (let i=0; i<this.clients.length; i++) { | ||
const client = this.clients[i]; | ||
const filteredState = this.onPatch(client, this.state.clone()); | ||
send(client, filteredState.encode()); | ||
} | ||
} else { | ||
this.broadcast(this.state.encode()); | ||
} | ||
} | ||
} | ||
``` | ||
## Benchmarks: | ||
@@ -263,2 +225,15 @@ | ||
## Why | ||
Initial thoghts/assumptions, for Colyseus: | ||
- little to no bottleneck for detecting state changes. | ||
- have a schema definition on both server and client | ||
- better experience on staticaly-typed languages (C#, C++) | ||
- mutations should be cheap. | ||
Practical Colyseus issues this should solve: | ||
- Avoid decoding large objects that haven't been patched | ||
- Allow to send different patches for each client | ||
- Better developer experience on statically-typed languages | ||
## Inspiration: | ||
@@ -265,0 +240,0 @@ |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
91409
29
2302
243