@axpecter/lync 2.2.1 → 2.3.1

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.
Files changed (62) hide show
  1. package/README.md +95 -143
  2. package/package.json +1 -1
  3. package/src/Types.luau +22 -16
  4. package/src/api/Group.luau +40 -33
  5. package/src/api/Packet.luau +68 -54
  6. package/src/api/Query.luau +103 -65
  7. package/src/api/Scope.luau +26 -10
  8. package/src/api/Signal.luau +43 -52
  9. package/src/codec/Base.luau +21 -14
  10. package/src/codec/composite/Array.luau +56 -134
  11. package/src/codec/composite/Map.luau +103 -72
  12. package/src/codec/composite/Optional.luau +6 -2
  13. package/src/codec/composite/Shared.luau +160 -69
  14. package/src/codec/composite/Struct.luau +283 -42
  15. package/src/codec/composite/Tagged.luau +13 -16
  16. package/src/codec/composite/Tuple.luau +21 -15
  17. package/src/codec/datatype/Buffer.luau +6 -4
  18. package/src/codec/datatype/CFrame.luau +56 -44
  19. package/src/codec/datatype/Color.luau +10 -10
  20. package/src/codec/datatype/Instance.luau +15 -12
  21. package/src/codec/datatype/IntVector.luau +2 -2
  22. package/src/codec/datatype/NumberRange.luau +15 -8
  23. package/src/codec/datatype/Ray.luau +13 -14
  24. package/src/codec/datatype/Rect.luau +12 -12
  25. package/src/codec/datatype/Region.luau +13 -14
  26. package/src/codec/datatype/Sequence.luau +87 -65
  27. package/src/codec/datatype/String.luau +17 -10
  28. package/src/codec/datatype/UDim.luau +10 -8
  29. package/src/codec/datatype/Vector.luau +20 -59
  30. package/src/codec/meta/Auto.luau +94 -126
  31. package/src/codec/meta/Bitfield.luau +12 -14
  32. package/src/codec/meta/Custom.luau +3 -1
  33. package/src/codec/meta/Enum.luau +9 -9
  34. package/src/codec/meta/Float.luau +6 -28
  35. package/src/codec/meta/Nothing.luau +1 -1
  36. package/src/codec/meta/Unknown.luau +10 -7
  37. package/src/codec/primitive/Bool.luau +6 -4
  38. package/src/codec/primitive/Float16.luau +5 -2
  39. package/src/codec/primitive/Int.luau +5 -6
  40. package/src/codec/primitive/Number.luau +6 -4
  41. package/src/codec/primitive/Signed.luau +2 -2
  42. package/src/codec/primitive/Varint.luau +69 -38
  43. package/src/index.d.ts +161 -53
  44. package/src/init.luau +109 -103
  45. package/src/internal/Baseline.luau +9 -1
  46. package/src/internal/Channel.luau +153 -154
  47. package/src/internal/Middleware.luau +22 -6
  48. package/src/internal/Pool.luau +12 -4
  49. package/src/internal/Registry.luau +25 -16
  50. package/src/internal/Transport.luau +1 -1
  51. package/src/transport/Bridge.luau +47 -33
  52. package/src/transport/Client.luau +25 -21
  53. package/src/transport/Gate.luau +227 -172
  54. package/src/transport/Reader.luau +162 -105
  55. package/src/transport/Server.luau +46 -57
  56. package/src/util/Array.luau +18 -0
  57. package/src/util/Buffer.luau +92 -0
  58. package/src/util/Constants.luau +30 -0
  59. package/src/util/Log.luau +68 -0
  60. package/src/util/Player.luau +14 -0
  61. package/src/util/Quantize.luau +60 -0
  62. package/src/internal/Util.luau +0 -26
@@ -7,34 +7,37 @@ local RunService = game:GetService("RunService")
7
7
 
8
8
  local Baseline = require(script.Parent.Parent.internal.Baseline)
9
9
  local Channel = require(script.Parent.Parent.internal.Channel)
10
+ local Log = require(script.Parent.Parent.util.Log)
10
11
  local Middleware = require(script.Parent.Parent.internal.Middleware)
12
+ local Player = require(script.Parent.Parent.util.Player)
11
13
  local Registry = require(script.Parent.Parent.internal.Registry)
12
14
  local Transport = require(script.Parent.Parent.internal.Transport)
13
15
  local Types = require(script.Parent.Parent.Types)
14
- local Util = require(script.Parent.Parent.internal.Util)
15
16
 
16
17
  -- Constants --------------------------------------------------------------
17
18
 
18
19
  local IS_SERVER = RunService:IsServer()
19
20
 
21
+ -- mode: TS_* tag; headerSize: 1 (frame byte) + timestamp bytes (1, 2, or 8).
22
+ local TS_MODES: { [string]: { mode: number, headerSize: number } } = table.freeze({
23
+ frame = table.freeze({ mode = Channel.TS_FRAME, headerSize = 2 }),
24
+ offset = table.freeze({ mode = Channel.TS_OFFSET, headerSize = 3 }),
25
+ full = table.freeze({ mode = Channel.TS_FULL, headerSize = 9 }),
26
+ })
27
+
28
+ local EMPTY_OPTIONS: Types.PacketOptions = table.freeze({})
29
+
20
30
  -- Private ----------------------------------------------------------------
21
31
 
22
- local isPlayer = Util.isPlayer
32
+ local isPlayer = Player.is
23
33
 
24
34
  local function resolveTimestampMode(ts: ("frame" | "offset" | "full")?): (number, number)
25
- if ts == "frame" then
26
- Channel.enableTimestamps()
27
- return Channel.TS_FRAME, 2
28
- end
29
- if ts == "offset" then
30
- Channel.enableTimestamps()
31
- return Channel.TS_OFFSET, 3
35
+ if ts == nil then
36
+ return Channel.TS_NONE, 1
32
37
  end
33
- if ts == "full" then
34
- Channel.enableTimestamps()
35
- return Channel.TS_FULL, 9
36
- end
37
- return Channel.TS_NONE, 1
38
+ local entry = TS_MODES[ts]
39
+ Channel.enableTimestamps()
40
+ return entry.mode, entry.headerSize
38
41
  end
39
42
 
40
43
  -- Public types -----------------------------------------------------------
@@ -63,17 +66,18 @@ function Packet.define<T>(
63
66
  codec: Types.InternalCodec<T>,
64
67
  options: Types.PacketOptions?
65
68
  ): PacketHandle<T>
66
- local opts = options or ({} :: Types.PacketOptions)
69
+ local opts = options or EMPTY_OPTIONS
67
70
  local isUnreliable = opts.unreliable or false
68
71
 
72
+ -- Delta + unreliable is unsafe: a dropped frame permanently desyncs the receiver baseline.
69
73
  if isUnreliable and codec._isDelta then
70
- error(`[Lync] Lync.packet: "{name}" cannot use delta codecs with unreliable transport`)
74
+ Log.error(
75
+ `"{name}" delta codecs need reliable transport (lost packets desync the baseline)`
76
+ )
71
77
  end
72
78
 
73
79
  if codec._hasUnknown and not opts.validate then
74
- warn(
75
- `[Lync] Lync.packet: "{name}" uses unknown codec without validate; data bypasses schema validation`
76
- )
80
+ Log.warn(`"{name}" uses unknown codec without validate; data bypasses schema validation`)
77
81
  end
78
82
 
79
83
  if codec._isDelta then
@@ -109,26 +113,34 @@ function Packet.define<T>(
109
113
  end
110
114
  end
111
115
 
112
- local function applySend(data: any, player: Player?): any?
116
+ --[[
117
+ Run the send middleware chain. Returns (dropped, value). A nil value
118
+ is a legitimate payload (Lync.nothing); only the explicit Lync.DROP
119
+ sentinel or a hook returning DROP triggers the drop branch.
120
+ ]]
121
+ local function applySend(data: any, player: Player?): (boolean, any)
113
122
  if not Middleware.hasSendHooks() then
114
- return data
123
+ return false, data
115
124
  end
116
-
117
125
  local sendData = Middleware.runSend(data, name, player)
118
- if typeof(sendData) == "table" and sendData._lyncDrop then
119
- return nil
126
+ if typeof(sendData) == "table" and sendData._lyncKind == "drop" then
127
+ return true, nil
120
128
  end
121
- return sendData
129
+ return false, sendData
122
130
  end
123
131
 
124
- local function sendFromServer(data: any, target: any): ()
125
- if target == nil then
126
- error(`[Lync] Packet.send: "{name}" server send requires a target`)
132
+ -- Caller has already run send middleware once for the broadcast.
133
+ local function broadcast(players: { Player }, data: any): ()
134
+ for _, p in players do
135
+ sendToPlayer(p, data)
127
136
  end
137
+ bumpFires()
138
+ end
128
139
 
140
+ local function sendToTarget(data: any, target: any): ()
129
141
  if isPlayer(target) then
130
- local sendData = applySend(data, target :: Player)
131
- if sendData == nil then
142
+ local dropped, sendData = applySend(data, target :: Player)
143
+ if dropped then
132
144
  return
133
145
  end
134
146
  sendToPlayer(target :: Player, sendData)
@@ -137,57 +149,59 @@ function Packet.define<T>(
137
149
  end
138
150
 
139
151
  if typeof(target) == "table" then
140
- local sendData = applySend(data, nil)
141
- if sendData == nil then
152
+ local dropped, sendData = applySend(data, nil)
153
+ if dropped then
142
154
  return
143
155
  end
144
156
 
145
- if target._lyncAll then
146
- for _, player in Players:GetPlayers() do
147
- sendToPlayer(player, sendData)
148
- end
149
- bumpFires()
157
+ local kind = target._lyncKind
158
+ if kind == "all" then
159
+ broadcast(Players:GetPlayers(), sendData)
150
160
  return
151
161
  end
152
162
 
153
- if target._lyncExcept then
163
+ if kind == "except" then
154
164
  local excluded = target._excluded :: { [Player]: boolean }
155
- for _, player in Players:GetPlayers() do
156
- if not excluded[player] then
157
- sendToPlayer(player, sendData)
165
+ for _, p in Players:GetPlayers() do
166
+ if not excluded[p] then
167
+ sendToPlayer(p, sendData)
158
168
  end
159
169
  end
160
170
  bumpFires()
161
171
  return
162
172
  end
163
173
 
164
- if target._lyncGroup then
165
- for player in target._members :: { [Player]: boolean } do
166
- sendToPlayer(player, sendData)
174
+ if kind == "group" then
175
+ for p in target._members :: { [Player]: boolean } do
176
+ sendToPlayer(p, sendData)
167
177
  end
168
178
  bumpFires()
169
179
  return
170
180
  end
171
181
 
172
- for _, player in target do
173
- if isPlayer(player) then
174
- sendToPlayer(player :: Player, sendData)
182
+ -- Plain { Player } array.
183
+ for _, p in target do
184
+ if isPlayer(p) then
185
+ sendToPlayer(p :: Player, sendData)
175
186
  end
176
187
  end
177
188
  bumpFires()
178
189
  return
179
190
  end
180
191
 
181
- error(
182
- `[Lync] Packet.send: "{name}" expected Player, table, Group, or Lync.all as target, got {typeof(
183
- target
184
- )}`
185
- )
192
+ Log.error(`"{name}" expected Player, table, Group, or Lync.all, got {typeof(target)}`)
193
+ end
194
+
195
+ local function sendFromServer(data: any, target: any): ()
196
+ if target == nil then
197
+ Log.error(`"{name}" server send requires a target`)
198
+ end
199
+ sendToTarget(data, target)
186
200
  end
187
201
 
188
202
  local function sendFromClient(data: any): ()
189
- local sendData = applySend(data, nil)
190
- if sendData == nil then
203
+ local dropped, sendData = applySend(data, nil)
204
+ if dropped then
191
205
  return
192
206
  end
193
207
  local ch = Transport.client().getChannel(isUnreliable)
@@ -6,38 +6,82 @@ local Players = game:GetService("Players")
6
6
  local RunService = game:GetService("RunService")
7
7
 
8
8
  local Channel = require(script.Parent.Parent.internal.Channel)
9
+ local Log = require(script.Parent.Parent.util.Log)
10
+ local Player = require(script.Parent.Parent.util.Player)
9
11
  local Registry = require(script.Parent.Parent.internal.Registry)
10
12
  local Transport = require(script.Parent.Parent.internal.Transport)
11
13
  local Types = require(script.Parent.Parent.Types)
12
- local Util = require(script.Parent.Parent.internal.Util)
13
14
 
14
15
  -- Constants --------------------------------------------------------------
15
16
 
16
17
  local IS_SERVER = RunService:IsServer()
17
18
  local DEFAULT_TIMEOUT = 5
18
19
 
20
+ local EMPTY_OPTIONS: Types.QueryOptions = table.freeze({})
21
+
19
22
  -- State ------------------------------------------------------------------
20
23
 
21
24
  local _nextCorrId = 0
25
+ -- Correlation -> waiting thread / completion closure. Cleared on resolve or timeout.
22
26
  local _pending: { [number]: thread } = {}
23
27
  local _pendingGather: { [number]: (any) -> () } = {}
24
28
 
25
29
  -- Private ----------------------------------------------------------------
26
30
 
27
- local isPlayer = Util.isPlayer
31
+ local isPlayer = Player.is
28
32
 
29
33
  local function nextCorr(): number
30
34
  _nextCorrId += 1
31
35
  return _nextCorrId
32
36
  end
33
37
 
38
+ local function writeQueryTracked(
39
+ ch: Types.ChannelState,
40
+ reg: Types.Registration,
41
+ corrId: number,
42
+ codec: Types.InternalCodec<any>?,
43
+ data: any
44
+ ): ()
45
+ if Channel.statsEnabled() then
46
+ local before = ch.cursor
47
+ Channel.writeQuery(ch, reg.id, corrId, codec, data)
48
+ reg.bytesSent += ch.cursor - before
49
+ else
50
+ Channel.writeQuery(ch, reg.id, corrId, codec, data)
51
+ end
52
+ end
53
+
54
+ local function collectTargets(target: any): { Player }
55
+ local targets: { Player } = {}
56
+ if typeof(target) ~= "table" then
57
+ return targets
58
+ end
59
+
60
+ local kind = target._lyncKind
61
+ if kind == "all" then
62
+ for _, p in Players:GetPlayers() do
63
+ table.insert(targets, p)
64
+ end
65
+ elseif kind == "group" then
66
+ for p in target._members do
67
+ table.insert(targets, p)
68
+ end
69
+ else
70
+ for _, p in target do
71
+ if isPlayer(p) then
72
+ table.insert(targets, p :: Player)
73
+ end
74
+ end
75
+ end
76
+ return targets
77
+ end
78
+
34
79
  -- Public types -----------------------------------------------------------
35
80
 
36
81
  --[[
37
82
  Server `request(data, target)` returns `Resp?` for a Player target and
38
- `{ [Player]: Resp? }` for a multi-target table; Luau doesn't support
39
- overloads on table fields, so the type advertises `Resp?` (the common
40
- single-target case). Multi-target callers must cast the return.
83
+ `{ [Player]: Resp? }` for a multi-target table; Luau lacks overloads on
84
+ table fields, so the type advertises `Resp?` and multi-target callers cast.
41
85
  ]]
42
86
  export type QueryHandle<Req, Resp> = {
43
87
  handle: (
@@ -59,10 +103,11 @@ function Query.define<Req, Resp>(
59
103
  responseCodec: Types.InternalCodec<Resp>,
60
104
  options: Types.QueryOptions?
61
105
  ): QueryHandle<Req, Resp>
62
- local opts = options or ({} :: Types.QueryOptions)
106
+ local opts = options or EMPTY_OPTIONS
63
107
  local timeout = opts.timeout or DEFAULT_TIMEOUT
64
108
 
65
109
  local reqOpenFn = Channel.resolveOpenFn(Channel.TS_NONE, name)
110
+ -- Embedded "\0resp" suffix stays uniquely paired with the request reg.
66
111
  local respName = `{name}\0resp`
67
112
  local respOpenFn = Channel.resolveOpenFn(Channel.TS_NONE, respName)
68
113
 
@@ -98,6 +143,7 @@ function Query.define<Req, Resp>(
98
143
 
99
144
  reqReg.partner = respReg.id
100
145
 
146
+ -- Single active handler; replacing it disconnects the prior one via handlerToken.
101
147
  local activeHandler: ((any, Player?) -> any)? = nil
102
148
  local handlerToken = 0
103
149
 
@@ -122,18 +168,22 @@ function Query.define<Req, Resp>(
122
168
  end
123
169
 
124
170
  local ok, response = pcall(handler, value, sender)
171
+ if not ok then
172
+ Log.warn(`query "{name}" handler errored: {response}`)
173
+ end
174
+ -- pcall failure path returns nil response (writeQuery encodes a nil-MSB header).
125
175
  local payloadCodec = if ok then responseCodec else nil
126
176
  local payloadData = if ok then response else nil
127
177
 
128
- local ch = if IS_SERVER and sender
129
- then Transport.server().getChannel(sender, false)
130
- elseif not IS_SERVER then Transport.client().getChannel(false)
131
- else nil
178
+ local ch: Types.ChannelState? = if IS_SERVER
179
+ then (if sender then Transport.server().getChannel(sender, false) else nil)
180
+ else Transport.client().getChannel(false)
132
181
  if ch then
133
- Channel.writeQuery(ch, respReg.id, corrId, payloadCodec, payloadData)
182
+ writeQueryTracked(ch, respReg, corrId, payloadCodec, payloadData)
134
183
  end
135
184
  end)
136
185
 
186
+ -- Resolve the waiting thread with nil after `timeout` seconds.
137
187
  local function scheduleSingleTimeout(corrId: number, running: thread): ()
138
188
  task.delay(timeout, function()
139
189
  if _pending[corrId] == running then
@@ -143,10 +193,10 @@ function Query.define<Req, Resp>(
143
193
  end)
144
194
  end
145
195
 
146
- local function requestSingle(target: Player, data: any): any
196
+ -- Issue a single-target query on `ch` and yield until reply or timeout.
197
+ local function requestOnChannel(ch: Types.ChannelState, data: any): any
147
198
  local corrId = nextCorr()
148
- local ch = Transport.server().getChannel(target, false)
149
- Channel.writeQuery(ch, reqReg.id, corrId, requestCodec, data)
199
+ writeQueryTracked(ch, reqReg, corrId, requestCodec, data)
150
200
 
151
201
  local running = coroutine.running()
152
202
  _pending[corrId] = running
@@ -155,30 +205,41 @@ function Query.define<Req, Resp>(
155
205
  end
156
206
 
157
207
  local function requestMulti(targets: { Player }, data: any): { [Player]: any }
158
- if #targets == 0 then
208
+ local targetCount = #targets
209
+ if targetCount == 0 then
159
210
  return {}
160
211
  end
161
212
 
162
213
  local results: { [Player]: any } = {}
163
- local remaining = #targets
214
+ local remaining = targetCount
164
215
  local running = coroutine.running()
165
216
  local resolved = false
217
+ local corrIds = table.create(targetCount)
166
218
 
219
+ --[[
220
+ On timeout, surface the partial result table AND evict every
221
+ still-pending closure. Without eviction each unanswered closure
222
+ keeps `results`/`remaining`/`resolveOnce` alive: a slow leak.
223
+ ]]
167
224
  local function resolveOnce(): ()
168
225
  if resolved then
169
226
  return
170
227
  end
171
228
  resolved = true
229
+ for i = 1, targetCount do
230
+ _pendingGather[corrIds[i]] = nil
231
+ end
172
232
  task.spawn(running, results)
173
233
  end
174
234
 
175
235
  local Server = Transport.server()
176
- for _, player in targets do
236
+ for i, p in targets do
177
237
  local pCorr = nextCorr()
178
- local ch = Server.getChannel(player, false)
179
- Channel.writeQuery(ch, reqReg.id, pCorr, requestCodec, data)
238
+ corrIds[i] = pCorr
239
+ local ch = Server.getChannel(p, false)
240
+ writeQueryTracked(ch, reqReg, pCorr, requestCodec, data)
180
241
 
181
- local pRef = player
242
+ local pRef = p
182
243
  _pendingGather[pCorr] = function(response: any)
183
244
  results[pRef] = response
184
245
  remaining -= 1
@@ -192,54 +253,28 @@ function Query.define<Req, Resp>(
192
253
  return coroutine.yield()
193
254
  end
194
255
 
195
- local function collectTargets(target: any): { Player }
196
- local targets: { Player } = {}
197
- if typeof(target) ~= "table" then
198
- return targets
199
- end
200
-
201
- if target._lyncAll then
202
- for _, p in Players:GetPlayers() do
203
- table.insert(targets, p)
204
- end
205
- elseif target._lyncGroup then
206
- for player in target._members do
207
- table.insert(targets, player)
208
- end
209
- else
210
- for _, p in target do
211
- if isPlayer(p) then
212
- table.insert(targets, p :: Player)
213
- end
214
- end
215
- end
216
- return targets
217
- end
218
-
219
256
  local function requestFromServer(data: any, target: any): any
220
257
  if target == nil then
221
- error(`[Lync] Query.request: "{name}" server request requires a target`)
258
+ Log.error(`"{name}" server request requires a target`)
222
259
  end
223
260
  if isPlayer(target) then
224
- return requestSingle(target :: Player, data)
261
+ return requestOnChannel(Transport.server().getChannel(target :: Player, false), data)
225
262
  end
226
263
  return requestMulti(collectTargets(target), data)
227
264
  end
228
265
 
229
266
  local function requestFromClient(data: any): any
230
- local corrId = nextCorr()
231
- local ch = Transport.client().getChannel(false)
232
- Channel.writeQuery(ch, reqReg.id, corrId, requestCodec, data)
233
-
234
- local running = coroutine.running()
235
- _pending[corrId] = running
236
- scheduleSingleTimeout(corrId, running)
237
- return coroutine.yield()
267
+ return requestOnChannel(Transport.client().getChannel(false), data)
238
268
  end
239
269
 
240
270
  type Self = QueryHandle<Req, Resp>
241
271
 
242
272
  local handle = {
273
+ --[[
274
+ Replace the active handler. The returned Connection only clears
275
+ the active slot if no later :handle has overwritten it: token
276
+ comparison guards against a stale disconnect wiping a fresh handler.
277
+ ]]
243
278
  handle = function(
244
279
  _self: Self,
245
280
  fn: (request: Req, player: Player?) -> Resp?
@@ -248,17 +283,20 @@ function Query.define<Req, Resp>(
248
283
  local myToken = handlerToken
249
284
  activeHandler = fn :: any
250
285
 
251
- local conn = { connected = true }
252
- function conn.disconnect(self): ()
253
- if not self.connected then
254
- return
255
- end
256
- self.connected = false
257
- if handlerToken == myToken then
258
- activeHandler = nil
259
- end
260
- end
261
- return conn :: Types.Connection
286
+ local conn: Types.Connection
287
+ conn = {
288
+ connected = true,
289
+ disconnect = function(self): ()
290
+ if not self.connected then
291
+ return
292
+ end
293
+ self.connected = false
294
+ if handlerToken == myToken then
295
+ activeHandler = nil
296
+ end
297
+ end,
298
+ }
299
+ return conn
262
300
  end,
263
301
 
264
302
  request = function(_self: Self, data: Req, target: any?): Resp?
@@ -1,7 +1,6 @@
1
1
  --!strict
2
2
  --!optimize 2
3
- -- Batched connection lifecycle. Accepts Lync Connection,
4
- -- RBXScriptConnection, and any object with :disconnect() / :Disconnect().
3
+ -- Batched connection lifecycle for Lync, RBXScriptConnection, and disconnect()-style objects.
5
4
 
6
5
  local Types = require(script.Parent.Parent.Types)
7
6
 
@@ -25,21 +24,29 @@ local function pushConnection(self: Scope, conn: any): ()
25
24
  self._connections[idx] = conn
26
25
  end
27
26
 
27
+ -- Routes :on / :once through `methodName` so both call sites share insert + size bookkeeping.
28
+ local function attach(
29
+ self: Scope,
30
+ source: any,
31
+ fn: (...any) -> (),
32
+ methodName: string
33
+ ): Types.Connection
34
+ local conn = source[methodName](source, fn)
35
+ pushConnection(self, conn)
36
+ return conn
37
+ end
38
+
28
39
  -- Public -----------------------------------------------------------------
29
40
 
30
41
  local ScopeMeta = {}
31
42
  ScopeMeta.__index = ScopeMeta
32
43
 
33
44
  function ScopeMeta.on(self: Scope, source: any, fn: (...any) -> ()): Types.Connection
34
- local conn = source:on(fn)
35
- pushConnection(self, conn)
36
- return conn
45
+ return attach(self, source, fn, "on")
37
46
  end
38
47
 
39
48
  function ScopeMeta.once(self: Scope, source: any, fn: (...any) -> ()): Types.Connection
40
- local conn = source:once(fn)
41
- pushConnection(self, conn)
42
- return conn
49
+ return attach(self, source, fn, "once")
43
50
  end
44
51
 
45
52
  ScopeMeta.add = pushConnection
@@ -48,6 +55,11 @@ function ScopeMeta.size(self: Scope): number
48
55
  return self._count
49
56
  end
50
57
 
58
+ --[[
59
+ Disconnect every tracked source. The shape sniff handles three kinds:
60
+ Roblox RBXScriptConnection (:Disconnect), Lync Connection (:disconnect),
61
+ and tables exposing either capitalization.
62
+ ]]
51
63
  function ScopeMeta.destroy(self: Scope): ()
52
64
  local conns = self._connections
53
65
  local count = self._count
@@ -56,8 +68,12 @@ function ScopeMeta.destroy(self: Scope): ()
56
68
  conns[i] = nil
57
69
  if typeof(conn) == "RBXScriptConnection" then
58
70
  (conn :: RBXScriptConnection):Disconnect()
59
- elseif typeof(conn) == "table" and conn.disconnect then
60
- conn:disconnect()
71
+ elseif typeof(conn) == "table" then
72
+ if conn.disconnect then
73
+ conn:disconnect()
74
+ elseif conn.Disconnect then
75
+ conn:Disconnect()
76
+ end
61
77
  end
62
78
  end
63
79
  self._count = 0