@rbxts/replion 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/package.json +28 -0
- package/src/Client.lua +146 -0
- package/src/Server.lua +195 -0
- package/src/Shared.lua +18 -0
- package/src/index.d.ts +20 -0
- package/src/init.lua +12 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rbxts/replion",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "src/init.lua",
|
|
6
|
+
"scripts": {},
|
|
7
|
+
"keywords": [],
|
|
8
|
+
"author": "CriShoux",
|
|
9
|
+
"license": "ISC",
|
|
10
|
+
"type": "commonjs",
|
|
11
|
+
"types": "src/index.d.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@rbxts/compiler-types": "^3.0.0-types.0",
|
|
20
|
+
"@rbxts/types": "^1.0.896",
|
|
21
|
+
"roblox-ts": "^3.0.0",
|
|
22
|
+
"typescript": "^5.9.3"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@rbxts/sift": "^0.0.11",
|
|
26
|
+
"@rbxts/sleitnick-signal": "^1.0.8"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/Client.lua
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
local Shared = require(script.Parent.Shared);
|
|
3
|
+
local Signal = require(dependencies:FindFirstChild('Signal') or dependencies['sleitnick-signal']);
|
|
4
|
+
|
|
5
|
+
type Observer = (newValue: any, oldValue: any) -> ();
|
|
6
|
+
|
|
7
|
+
local remote = script.Parent:WaitForChild(Shared.REMOTE_NAME) :: RemoteEvent;
|
|
8
|
+
|
|
9
|
+
local function deepMergeAndApply(target: any, updates: any)
|
|
10
|
+
for key, value in updates do
|
|
11
|
+
if value == Shared.NIL_SENTINEL then
|
|
12
|
+
target[key] = nil;
|
|
13
|
+
elseif typeof(value) == 'table' and typeof(target[key]) == 'table' then
|
|
14
|
+
deepMergeAndApply(target[key], value);
|
|
15
|
+
else
|
|
16
|
+
target[key] = value;
|
|
17
|
+
end;
|
|
18
|
+
end;
|
|
19
|
+
end;
|
|
20
|
+
|
|
21
|
+
local Client = {};
|
|
22
|
+
Client.__index = Client;
|
|
23
|
+
|
|
24
|
+
type Replion = typeof(setmetatable({}, Client));
|
|
25
|
+
|
|
26
|
+
Client._activeReplions = {} :: { [string]: Replion };
|
|
27
|
+
Client._replionInitializedSignals = {} :: { [string]: Signal.Signal<Replion> };
|
|
28
|
+
|
|
29
|
+
function Client.waitForReplion(channel: string)
|
|
30
|
+
local existing = Client._activeReplions[channel];
|
|
31
|
+
if existing then return existing; end;
|
|
32
|
+
|
|
33
|
+
local initSignal = Client._replionInitializedSignals[channel];
|
|
34
|
+
if initSignal then return initSignal:Wait(); end;
|
|
35
|
+
|
|
36
|
+
initSignal = Signal.new();
|
|
37
|
+
Client._replionInitializedSignals[channel] = initSignal;
|
|
38
|
+
|
|
39
|
+
remote:FireServer('I', channel);
|
|
40
|
+
|
|
41
|
+
local replion = initSignal:Wait();
|
|
42
|
+
|
|
43
|
+
Client._replionInitializedSignals[channel] = nil;
|
|
44
|
+
|
|
45
|
+
return replion;
|
|
46
|
+
end;
|
|
47
|
+
|
|
48
|
+
function Client.new(channel: string, data: Shared.GenericDataTable)
|
|
49
|
+
local self = setmetatable({}, Client);
|
|
50
|
+
|
|
51
|
+
self.channel = channel;
|
|
52
|
+
self.data = data;
|
|
53
|
+
self._signals = {} :: { [string]: any };
|
|
54
|
+
self._allSignal = Signal.new();
|
|
55
|
+
|
|
56
|
+
return self;
|
|
57
|
+
end;
|
|
58
|
+
|
|
59
|
+
function Client:get(key: (string | {string})?)
|
|
60
|
+
if not key then return self.data; end;
|
|
61
|
+
if typeof(key) == 'table' then
|
|
62
|
+
return Shared.getNestedValue(self.data, key);
|
|
63
|
+
end;
|
|
64
|
+
|
|
65
|
+
return self.data[key];
|
|
66
|
+
end;
|
|
67
|
+
|
|
68
|
+
function Client:observe(key: string?, callback: Observer)
|
|
69
|
+
if not key then
|
|
70
|
+
task.spawn(callback, self.data, nil);
|
|
71
|
+
|
|
72
|
+
return self._allSignal:Connect(callback);
|
|
73
|
+
end;
|
|
74
|
+
|
|
75
|
+
if not self._signals[key] then
|
|
76
|
+
self._signals[key] = Signal.new();
|
|
77
|
+
end;
|
|
78
|
+
|
|
79
|
+
task.spawn(callback, self.data[key], nil);
|
|
80
|
+
|
|
81
|
+
return self._signals[key]:Connect(callback);
|
|
82
|
+
end;
|
|
83
|
+
|
|
84
|
+
function Client:_applyUpdates(updates: Shared.GenericDataTable)
|
|
85
|
+
local oldData = table.clone(self.data); -- Note: Shallow copy instead of deep copy.
|
|
86
|
+
|
|
87
|
+
deepMergeAndApply(self.data, updates);
|
|
88
|
+
|
|
89
|
+
local anyChanged = false;
|
|
90
|
+
|
|
91
|
+
for key, newValue in updates do
|
|
92
|
+
local oldValue = oldData[key];
|
|
93
|
+
|
|
94
|
+
-- If it's a table, we assume it changed if it's in the update list.
|
|
95
|
+
if typeof(newValue) == 'table' then
|
|
96
|
+
anyChanged = true
|
|
97
|
+
local signal = self._signals[key];
|
|
98
|
+
if signal then
|
|
99
|
+
signal:Fire(self.data[key], self.data[key]); -- Note: Sends improper 'old' argument due to shallow copy optimization.
|
|
100
|
+
end;
|
|
101
|
+
elseif self.data[key] ~= oldValue then
|
|
102
|
+
anyChanged = true;
|
|
103
|
+
local signal = self._signals[key];
|
|
104
|
+
if signal then
|
|
105
|
+
signal:Fire(self.data[key], oldValue);
|
|
106
|
+
end;
|
|
107
|
+
end;
|
|
108
|
+
end;
|
|
109
|
+
|
|
110
|
+
if anyChanged then
|
|
111
|
+
self._allSignal:Fire(self.data, updates);
|
|
112
|
+
end;
|
|
113
|
+
end;
|
|
114
|
+
|
|
115
|
+
function Client:destroy()
|
|
116
|
+
self._allSignal:Destroy();
|
|
117
|
+
for _, signal in self._signals do
|
|
118
|
+
signal:Destroy();
|
|
119
|
+
end;
|
|
120
|
+
self._signals = {};
|
|
121
|
+
end;
|
|
122
|
+
|
|
123
|
+
remote.OnClientEvent:Connect(function(type, channel, payload)
|
|
124
|
+
if type == 'I' then
|
|
125
|
+
local newReplion = Client.new(channel, payload);
|
|
126
|
+
Client._activeReplions[channel] = newReplion;
|
|
127
|
+
|
|
128
|
+
local initializedSignal = Client._replionInitializedSignals[channel];
|
|
129
|
+
if not initializedSignal then return; end;
|
|
130
|
+
|
|
131
|
+
initializedSignal:Fire(newReplion);
|
|
132
|
+
elseif type == 'U' then
|
|
133
|
+
local replion = Client._activeReplions[channel];
|
|
134
|
+
if not replion then return; end;
|
|
135
|
+
|
|
136
|
+
replion:_applyUpdates(payload);
|
|
137
|
+
elseif type == 'D' then
|
|
138
|
+
local replion = Client._activeReplions[channel]
|
|
139
|
+
if not replion then return; end;
|
|
140
|
+
|
|
141
|
+
replion:destroy();
|
|
142
|
+
Client._activeReplions[channel] = nil;
|
|
143
|
+
end;
|
|
144
|
+
end);
|
|
145
|
+
|
|
146
|
+
return Client;
|
package/src/Server.lua
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
local Players = game:GetService('Players');
|
|
3
|
+
|
|
4
|
+
local dependencies = script.Parent.Parent;
|
|
5
|
+
local Shared = require(script.Parent.Shared);
|
|
6
|
+
local Sift = require(dependencies:FindFirstChild('Sift') or dependencies.sift.out);
|
|
7
|
+
|
|
8
|
+
type ServerConfig = {
|
|
9
|
+
channel: string;
|
|
10
|
+
replicateTo: Player?;
|
|
11
|
+
data: Shared.GenericDataTable;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
local remote = script.Parent:FindFirstChild(Shared.REMOTE_NAME) :: RemoteEvent;
|
|
15
|
+
if not remote then
|
|
16
|
+
remote = Instance.new('RemoteEvent');
|
|
17
|
+
remote.Name = Shared.REMOTE_NAME;
|
|
18
|
+
remote.Parent = script.Parent;
|
|
19
|
+
end;
|
|
20
|
+
|
|
21
|
+
local function setNestedValue(root: any, path: {string}, value: any)
|
|
22
|
+
local current = root;
|
|
23
|
+
for i = 1, #path - 1 do
|
|
24
|
+
local key = path[i];
|
|
25
|
+
if typeof(current[key]) ~= 'table' then
|
|
26
|
+
current[key] = {};
|
|
27
|
+
end;
|
|
28
|
+
current = current[key];
|
|
29
|
+
end;
|
|
30
|
+
|
|
31
|
+
local lastKey = path[#path];
|
|
32
|
+
local oldValue = current[lastKey];
|
|
33
|
+
current[lastKey] = value;
|
|
34
|
+
return oldValue;
|
|
35
|
+
end;
|
|
36
|
+
|
|
37
|
+
local function queueNestedUpdate(queue: any, path: {string}, value: any)
|
|
38
|
+
local current = queue;
|
|
39
|
+
for i = 1, #path - 1 do
|
|
40
|
+
local key = path[i];
|
|
41
|
+
if typeof(current[key]) ~= 'table' then
|
|
42
|
+
current[key] = {};
|
|
43
|
+
end;
|
|
44
|
+
current = current[key];
|
|
45
|
+
end;
|
|
46
|
+
|
|
47
|
+
current[path[#path]] = if value == nil then Shared.NIL_SENTINEL else value;
|
|
48
|
+
end;
|
|
49
|
+
|
|
50
|
+
local Server = {};
|
|
51
|
+
Server.__index = Server;
|
|
52
|
+
|
|
53
|
+
type Replion = typeof(setmetatable({}, Server));
|
|
54
|
+
|
|
55
|
+
Server._activeReplions = {} :: { [Player]: { [string]: Replion } };
|
|
56
|
+
Server._globalReplions = {} :: { [string]: Replion };
|
|
57
|
+
|
|
58
|
+
-- This method is private because this it isn't able to give correct types for the found Replion, and that's unideal for public use.
|
|
59
|
+
function Server._getPlayerReplion(player: Player, channel: string)
|
|
60
|
+
local playerReplions = Server._activeReplions[player];
|
|
61
|
+
if not playerReplions then return nil; end;
|
|
62
|
+
|
|
63
|
+
return playerReplions[channel];
|
|
64
|
+
end;
|
|
65
|
+
|
|
66
|
+
-- This method is private because this it isn't able to give correct types for the found Replion, and that's unideal for public use.
|
|
67
|
+
function Server._getGlobalReplion(channel: string)
|
|
68
|
+
return Server._globalReplions[channel];
|
|
69
|
+
end;
|
|
70
|
+
|
|
71
|
+
function Server.new(config: ServerConfig)
|
|
72
|
+
local self = setmetatable({}, Server);
|
|
73
|
+
|
|
74
|
+
self.player = config.replicateTo;
|
|
75
|
+
self.channel = config.channel;
|
|
76
|
+
self.data = table.clone(config.data);
|
|
77
|
+
|
|
78
|
+
self._queuedUpdates = {} :: Shared.GenericDataTable;
|
|
79
|
+
self._isQueued = false;
|
|
80
|
+
self._destroyed = false;
|
|
81
|
+
|
|
82
|
+
if self.player then
|
|
83
|
+
local playerReplions = Server._activeReplions[self.player];
|
|
84
|
+
if not playerReplions then
|
|
85
|
+
playerReplions = {};
|
|
86
|
+
Server._activeReplions[self.player] = playerReplions;
|
|
87
|
+
end;
|
|
88
|
+
playerReplions[self.channel] = self;
|
|
89
|
+
else
|
|
90
|
+
Server._globalReplions[self.channel] = self;
|
|
91
|
+
end;
|
|
92
|
+
|
|
93
|
+
return self;
|
|
94
|
+
end;
|
|
95
|
+
|
|
96
|
+
function Server:set(key: string | {string}, value: any)
|
|
97
|
+
if self._destroyed then return; end;
|
|
98
|
+
|
|
99
|
+
local path = if typeof(key) == 'table' then key else {key};
|
|
100
|
+
local oldValue = Shared.getNestedValue(self.data, path);
|
|
101
|
+
|
|
102
|
+
local newValue = if typeof(value) == 'function' then value(oldValue) else value;
|
|
103
|
+
|
|
104
|
+
if oldValue == newValue then return; end;
|
|
105
|
+
|
|
106
|
+
setNestedValue(self.data, path, newValue);
|
|
107
|
+
queueNestedUpdate(self._queuedUpdates, path, newValue);
|
|
108
|
+
self:_scheduleUpdatesFlush();
|
|
109
|
+
end;
|
|
110
|
+
|
|
111
|
+
function Server:get(key: (string | {string})?)
|
|
112
|
+
if not key then return self.data; end;
|
|
113
|
+
if typeof(key) == 'table' then
|
|
114
|
+
return Shared.getNestedValue(self.data, key);
|
|
115
|
+
end;
|
|
116
|
+
|
|
117
|
+
return self.data[key];
|
|
118
|
+
end;
|
|
119
|
+
|
|
120
|
+
function Server:destroy()
|
|
121
|
+
self._destroyed = true;
|
|
122
|
+
self.data = {};
|
|
123
|
+
self._queuedUpdates = {};
|
|
124
|
+
|
|
125
|
+
if self.player then
|
|
126
|
+
local playerReplions = self._activeReplions[self.player];
|
|
127
|
+
if not playerReplions then return; end;
|
|
128
|
+
|
|
129
|
+
playerReplions[self.channel] = nil;
|
|
130
|
+
remote:FireClient(self.player, 'D', self.channel);
|
|
131
|
+
else
|
|
132
|
+
self._globalReplions[self.channel] = nil;
|
|
133
|
+
remote:FireAllClients('D', self.channel);
|
|
134
|
+
end;
|
|
135
|
+
end;
|
|
136
|
+
|
|
137
|
+
function Server:_scheduleUpdatesFlush()
|
|
138
|
+
if self._isQueued then return; end;
|
|
139
|
+
self._isQueued = true;
|
|
140
|
+
|
|
141
|
+
task.defer(function()
|
|
142
|
+
if self._destroyed then return; end;
|
|
143
|
+
self:_flushUpdates();
|
|
144
|
+
end);
|
|
145
|
+
end;
|
|
146
|
+
|
|
147
|
+
function Server:_flushUpdates()
|
|
148
|
+
self._isQueued = false;
|
|
149
|
+
|
|
150
|
+
local updates = self._queuedUpdates;
|
|
151
|
+
|
|
152
|
+
local nUpdates = Sift.Dictionary.count(updates);
|
|
153
|
+
if nUpdates == 0 then return; end;
|
|
154
|
+
|
|
155
|
+
if self.player then
|
|
156
|
+
remote:FireClient(self.player, 'U', self.channel, updates);
|
|
157
|
+
else
|
|
158
|
+
remote:FireAllClients('U', self.channel, updates);
|
|
159
|
+
end;
|
|
160
|
+
self._queuedUpdates = {};
|
|
161
|
+
end;
|
|
162
|
+
|
|
163
|
+
function Server:_sendInitial(player: Player?)
|
|
164
|
+
local receiver = player or self.player;
|
|
165
|
+
if not receiver then return; end;
|
|
166
|
+
|
|
167
|
+
remote:FireClient(receiver, 'I', self.channel, self.data);
|
|
168
|
+
end;
|
|
169
|
+
|
|
170
|
+
remote.OnServerEvent:Connect(function(player, action, ...)
|
|
171
|
+
if action == 'I' then
|
|
172
|
+
local channel = ...;
|
|
173
|
+
if not channel then return; end;
|
|
174
|
+
|
|
175
|
+
local replion = Server._getPlayerReplion(player, channel);
|
|
176
|
+
if not replion then
|
|
177
|
+
replion = Server._getGlobalReplion(channel);
|
|
178
|
+
if not replion then return; end;
|
|
179
|
+
end;
|
|
180
|
+
|
|
181
|
+
replion:_sendInitial(player);
|
|
182
|
+
end;
|
|
183
|
+
end);
|
|
184
|
+
|
|
185
|
+
Players.PlayerRemoving:Connect(function(player)
|
|
186
|
+
local playerReplions = Server._activeReplions[player];
|
|
187
|
+
if not playerReplions then return; end;
|
|
188
|
+
|
|
189
|
+
for _, replion in playerReplions do
|
|
190
|
+
replion:destroy();
|
|
191
|
+
end;
|
|
192
|
+
Server._activeReplions[player] = nil;
|
|
193
|
+
end);
|
|
194
|
+
|
|
195
|
+
return Server;
|
package/src/Shared.lua
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
--!strict
|
|
2
|
+
export type GenericDataTable = { [string]: any };
|
|
3
|
+
|
|
4
|
+
local Shared = {};
|
|
5
|
+
|
|
6
|
+
Shared.REMOTE_NAME = 'ReplionNetwork';
|
|
7
|
+
Shared.NIL_SENTINEL = '\0\0__NIL__\0\0';
|
|
8
|
+
|
|
9
|
+
function Shared.getNestedValue(root: any, path: {string})
|
|
10
|
+
local current = root;
|
|
11
|
+
for _, key in path do
|
|
12
|
+
if typeof(current) ~= 'table' then return nil; end;
|
|
13
|
+
current = current[key];
|
|
14
|
+
end;
|
|
15
|
+
return current;
|
|
16
|
+
end;
|
|
17
|
+
|
|
18
|
+
return Shared;
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
interface State<T> {
|
|
2
|
+
get(): T;
|
|
3
|
+
set(newValue: T): void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
declare namespace Observers {
|
|
7
|
+
function state<T>(initialValue: T): State<T>;
|
|
8
|
+
function state<T>(): State<T | undefined>;
|
|
9
|
+
|
|
10
|
+
function observeAttribute<T extends AttributeValue>(instance: Instance, attribute: string, callback: (value: T | undefined) => (() => void) | void): () => void;
|
|
11
|
+
function observeState<T>(state: State<T>, callback: (value: T) => void): () => void;
|
|
12
|
+
function observePlayers(callback: (player: Player) => (() => void) | void): () => void;
|
|
13
|
+
function observeCharacters(callback: (char: Model, player: Player) => (() => void) | void): () => void;
|
|
14
|
+
function observeAttribute<T extends AttributeValue>(instance: Instance, attribute: string, callback: (value: T | undefined) => (() => void) | void): () => void;
|
|
15
|
+
function observeChildren(instance: Instance, callback: (child: Instance) => (() => void) | void): () => void;
|
|
16
|
+
function observeTag<T extends Instance>(tag: string, callback: (instance: T) => (() => void) | void, ancestors?: Instance[]): () => void;
|
|
17
|
+
function observeProperty<P extends Instance, K extends InstancePropertyNames<P>>(instance: P, property: K, callback: (value: P[K]) => (() => void) | void): () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export = Observers;
|
package/src/init.lua
ADDED