@axpecter/lync 1.5.1 → 2.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 CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  ```toml
15
15
  [dependencies]
16
- Lync = "axp3cter/lync@1.5.1"
16
+ Lync = "axp3cter/lync@2.0.0"
17
17
  ```
18
18
 
19
19
  **npm (roblox-ts)**
@@ -85,12 +85,12 @@ local Players = game:GetService("Players")
85
85
 
86
86
  local alive = Lync.createGroup("alive")
87
87
 
88
- Lync.onSend(function(data, name)
88
+ Lync.onSend(function(data, name, player)
89
89
  print("[out]", name)
90
90
  return data
91
91
  end)
92
92
 
93
- Lync.onDrop(function(player, reason, name)
93
+ Lync.onDrop(function(player, reason, name, data)
94
94
  warn(player.Name, "dropped", name, reason)
95
95
  end)
96
96
 
@@ -125,7 +125,7 @@ Net.Hit:listen(function(data, player)
125
125
  }, Lync.except(target))
126
126
  end)
127
127
 
128
- Net.Ping:listen(function()
128
+ Net.Ping:listen(function(request, player)
129
129
  return os.clock()
130
130
  end)
131
131
  ```
@@ -163,7 +163,7 @@ end
163
163
  | | What it does |
164
164
  |:---------|:------------|
165
165
  | `Lync.start()` | Sets up transport. Server creates remotes, client connects. Call once after all definitions. |
166
- | `Lync.VERSION` | `"1.5.1"` |
166
+ | `Lync.VERSION` | `"2.0.0"` |
167
167
 
168
168
  ## Packets
169
169
 
@@ -176,6 +176,7 @@ end
176
176
  | `rateLimit` | `{ maxPerSecond, burstAllowance? }` | No | Server-side token bucket. Burst defaults to maxPerSecond if you dont set it. |
177
177
  | `validate` | `(data, player) → (bool, string?)` | No | Server-side. Return `false, "reason"` to drop. Runs after NaN scan. |
178
178
  | `maxPayloadBytes` | number | No | Server-side. Max bytes a single batch of this packet can consume. Fires `onDrop` with reason `"size"` if exceeded. |
179
+ | `timestamp` | `"frame" \| "offset" \| "full"` | No | Prepends a timestamp to each item. `"frame"` = u8 wrapping counter (1B), `"offset"` = u16 ms (2B), `"full"` = f64 clock (8B). Listeners receive it as a third argument. |
179
180
 
180
181
  **Server, single `send` with targets:**
181
182
 
@@ -198,8 +199,8 @@ packet:send(data) -- send to server
198
199
 
199
200
  | Method | What it does |
200
201
  |:-------|:------------|
201
- | `packet:listen(fn(data, sender))` | Sender is `Player` on server, `nil` on client. Returns a Connection. |
202
- | `packet:once(fn(data, sender))` | Auto-disconnects after one fire. |
202
+ | `packet:listen(fn(data, sender, timestamp?))` | Sender is `Player` on server, `nil` on client. `timestamp` is present when the packet has a `timestamp` config. Returns a Connection. |
203
+ | `packet:once(fn(data, sender, timestamp?))` | Auto-disconnects after one fire. |
203
204
  | `packet:wait()` | Returns `(data, sender)`. |
204
205
  | `packet:disconnectAll()` | Kills all listeners on this packet. |
205
206
 
@@ -271,7 +272,7 @@ Access packets and queries by their short name on the returned object: `ns.Packe
271
272
 
272
273
  | Method | What it does |
273
274
  |:-------|:------------|
274
- | `ns:listenAll(fn(name, data, sender))` | Listens to every packet in the namespace. `name` is the short name without prefix. Returns a Connection. |
275
+ | `ns:listenAll(fn(name, data, sender, timestamp?))` | Listens to every packet in the namespace. `name` is the short name without prefix. Returns a Connection. |
275
276
  | `ns:onSend(fn(data, name, player) → data?)` | Send middleware that only runs for this namespace. Returns a remover. |
276
277
  | `ns:onReceive(fn(data, name, player) → data?)` | Receive middleware that only runs for this namespace. Returns a remover. |
277
278
  | `ns:disconnectAll()` | Kills all listeners made through `listenAll`. |
@@ -386,7 +387,7 @@ Used as the second argument to `packet:send()` on the server.
386
387
  | `Lync.f16` | 2 | ±65,504, roughly 3 digits of precision |
387
388
  | `Lync.f32` | 4 | IEEE 754 single |
388
389
  | `Lync.f64` | 8 | IEEE 754 double |
389
- | `Lync.bool` | 1 | true/false. Gets packed into bitfields when inside structs. |
390
+ | `Lync.bool` | 1 | true/false. Gets packed into bitfields when inside structs, and 8-per-byte when inside arrays. |
390
391
 
391
392
  ### Datatypes
392
393
 
@@ -417,7 +418,7 @@ Used as the second argument to `packet:send()` on the server.
417
418
  | Constructor | What it does |
418
419
  |:------------|:------------|
419
420
  | `Lync.struct({ key = codec })` | Named fields. Bools get packed into bitfields automatically. |
420
- | `Lync.array(codec, maxCount?)` | Variable length list with varint count. Optional `maxCount` rejects on read if exceeded. |
421
+ | `Lync.array(codec, maxCount?)` | Variable length list with varint count. Optional `maxCount` rejects on read if exceeded. Bool arrays are bitpacked (8 per byte). |
421
422
  | `Lync.map(keyCodec, valueCodec, maxCount?)` | Key-value pairs with varint count. Optional `maxCount` rejects on read if exceeded. |
422
423
  | `Lync.optional(codec)` | 1 byte flag, value only if present. |
423
424
  | `Lync.tuple(codec, codec, ...)` | Ordered positional values, no keys. |
@@ -502,17 +503,109 @@ Same data shapes and methodology as [Blink's benchmark suite](https://github.com
502
503
  > [!NOTE]
503
504
  > Lync benchmarks run on Ryzen 7 7800X3D, 32GB DDR5-4800. Other tool numbers are from [Blink's published benchmarks](https://github.com/1Axen/blink/blob/main/benchmark/Benchmarks.md) (v0.17.1, Ryzen 9 7900X, 34GB DDR5-4800). Different CPUs so FPS numbers arent directly comparable but bandwidth numbers are since Kbps is scaled by 60/FPS. Lync hits the 60 FPS frame cap in both tests.
504
505
 
506
+ ## Stats
507
+
508
+ Off by default. Call `Lync.enableStats()` before `Lync.start()` to activate. When disabled, zero overhead on send and receive paths.
509
+
510
+ Per-packet counters are available directly on the Packet object. Per-player counters are available via `Lync.getPlayerStats()`.
511
+
512
+ ```luau
513
+ Lync.enableStats()
514
+ Lync.start()
515
+
516
+ -- Per-packet (both sides)
517
+ print(Net.State:getBytesSent(), Net.State:getFires(), Net.State:getDrops())
518
+
519
+ -- Per-player (server only)
520
+ local stats = Lync.getPlayerStats(player)
521
+ if stats then
522
+ print(stats.bytesSent, stats.bytesReceived)
523
+ end
524
+
525
+ Lync.resetStats() -- zeros everything in-place
526
+ ```
527
+
528
+ | Function / Method | What it does |
529
+ |:-------|:----------------|
530
+ | `Lync.enableStats()` | Activates stat counters. Call before `start()`. |
531
+ | `packet:getBytesSent()` | Wire bytes produced (includes batch header overhead). |
532
+ | `packet:getBytesReceived()` | Wire bytes consumed on receive. |
533
+ | `packet:getFires()` | Send fire count. |
534
+ | `packet:getRecvFires()` | Receive fire count. |
535
+ | `packet:getDrops()` | Gate rejections (rate limit, NaN, validate). |
536
+ | `Lync.getPlayerStats(player)` | Returns `{ bytesSent, bytesReceived }` or nil. Server only. |
537
+ | `Lync.resetStats()` | Zeros all counters in-place. |
538
+
539
+ ## Flush Control
540
+
541
+ By default Lync flushes at 60hz (every Heartbeat). You can change this at runtime.
542
+
543
+ ```luau
544
+ Lync.setFlushRate(30) -- flush every ~33ms instead of ~16ms
545
+ Lync.flush() -- force an immediate flush, resets the accumulator
546
+ ```
547
+
548
+ | Function | What it does |
549
+ |:---------|:------------|
550
+ | `Lync.setFlushRate(hz)` | 1 to 60. Default 60. Callable at runtime. At 60hz, flushes every Heartbeat directly (no accumulator). Below 60, uses an elapsed-time accumulator with drift correction. |
551
+ | `Lync.flush()` | Immediate flush. Skips the next scheduled Heartbeat flush to prevent double-sending and XOR chain desync. |
552
+
553
+ ## Security
554
+
555
+ ### Bandwidth Throttle
556
+
557
+ Server-side per-player bandwidth strike counter. Counts consecutive oversized frames with decay. Protects against clients flooding the server.
558
+
559
+ ```luau
560
+ Lync.setBandwidthLimit(16384, 10) -- 16KB soft limit, 10 strikes before drop
561
+ ```
562
+
563
+ Fires `onDrop` with reason `"bandwidth"` when a player exceeds the threshold. Read failures (corrupted buffers, XOR desync) also count as strikes.
564
+
565
+ ### Unknown Codec Warning
566
+
567
+ If a packet uses `Lync.unknown` anywhere in its codec tree without a `validate` callback, Lync prints a warning at define time. The `unknown` codec bypasses schema validation entirely; client data goes through Roblox's sidecar without type checking. Adding `validate` suppresses the warning.
568
+
569
+ ## Packet Capture
570
+
571
+ Server-only debug tool. Records raw and XOR'd buffer hex for analysis.
572
+
573
+ ```luau
574
+ Lync.startCapture("My test")
575
+ -- fire packets...
576
+ Lync.flush()
577
+ Lync.stopCapture()
578
+
579
+ Lync.startCapture("Another test")
580
+ -- fire packets...
581
+ Lync.flush()
582
+ Lync.stopCapture()
583
+
584
+ Lync.dumpCaptures() -- writes JSON to ServerStorage.LyncCapture
585
+ ```
586
+
587
+ Each entry contains the label, frame number, raw hex (pre-XOR), XOR'd hex (post-XOR, nil for unreliable), byte count, and refs count. Hex is capped at 512 bytes per buffer to keep the output manageable.
588
+
589
+ | Function | What it does |
590
+ |:---------|:------------|
591
+ | `Lync.startCapture(label?)` | Start recording. Tags entries with the label. |
592
+ | `Lync.stopCapture()` | Stop recording. |
593
+ | `Lync.dumpCaptures()` | Writes all entries as JSON to a StringValue in ServerStorage, then clears. |
594
+
505
595
  ## Limits & Configuration
506
596
 
507
- Call these before `Lync.start()`.
597
+ Call these before `Lync.start()` unless noted otherwise.
508
598
 
509
599
  | What | Default | How to change | Notes |
510
600
  |:-----|--------:|:--------------|:------|
511
601
  | Packet types | 255 | Cant change | u8 on the wire. Each query eats 2 IDs. |
512
602
  | Buffer per channel per frame | 256 KB | `Lync.setChannelMaxSize(n)` | 4 KB to 1 MB. |
513
- | Concurrent queries | 65,536 | Cant change | u16 correlation IDs. Freed on response or timeout. `Lync.queryPendingCount()` returns in-flight count. |
603
+ | Concurrent queries | 65,536 | Cant change | Varint correlation IDs. Freed on response or timeout. `Lync.queryPendingCount()` returns in-flight count. Queries default to 30/s rate limit. |
604
+ | Stats | Off | `Lync.enableStats()` | Zero overhead when off. Counters on packets and players. |
514
605
  | NaN/inf scan depth | 16 | `Lync.setValidationDepth(n)` | 4 to 32. |
515
606
  | Channel pool | 16 | `Lync.setPoolSize(n)` | 2 to 128. Extra gets GCd. |
607
+ | Flush rate | 60 hz | `Lync.setFlushRate(n)` | 1 to 60. Runtime-safe. |
608
+ | Bandwidth limit | 16 KB / 10 strikes | `Lync.setBandwidthLimit(n, m)` | Server-only. Per-player. |
516
609
  | Namespaces | 64 | Cant change | |
517
610
  | Delta + unreliable | Nope | Cant change | Errors at define time. |
518
611
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@axpecter/lync",
3
- "version": "1.5.1",
4
- "description": "Buffer networking for Roblox. Delta compression, XOR framing, built-in security.",
3
+ "version": "2.0.0",
4
+ "description": "Buffer networking for Roblox. Delta compression, XOR framing, built-in security",
5
5
  "main": "src/init.luau",
6
6
  "types": "src/index.d.ts",
7
7
  "module": "commonjs",
package/src/Types.luau CHANGED
@@ -41,6 +41,7 @@ export type PacketConfig<T> = {
41
41
  rateLimit: RateLimitConfig?,
42
42
  validate: ((data: T, player: Player) -> (boolean, string?))?,
43
43
  maxPayloadBytes: number?,
44
+ timestamp: ("frame" | "offset" | "full")?,
44
45
  }
45
46
 
46
47
  export type QueryConfig<Req, Resp> = {
@@ -76,6 +77,15 @@ export type Registration = {
76
77
  validate: ((data: any, player: Player) -> (boolean, string?))?,
77
78
  needsGate: boolean,
78
79
  maxPayloadBytes: number?,
80
+ timestampMode: number,
81
+ _openFn: (ch: ChannelState, id: number, name: string) -> (), -- resolved at define time (#2)
82
+
83
+ -- Stats counters (mutable, incremented at runtime)
84
+ bytesSent: number,
85
+ bytesReceived: number,
86
+ fires: number,
87
+ recvFires: number,
88
+ drops: number,
79
89
  }
80
90
 
81
91
  export type Connection = {
@@ -62,7 +62,12 @@ export type Namespace = typeof(setmetatable({} :: NamespaceFields, NamespaceImpl
62
62
  -- Listens on every packet in this namespace. Returns a Connection.
63
63
  function NamespaceImpl.listenAll(
64
64
  self: Namespace,
65
- callback: (name: string, data: any, sender: Player?) -> ()
65
+ callback: (
66
+ name: string,
67
+ data: any,
68
+ sender: Player?,
69
+ timestamp: number?
70
+ ) -> ()
66
71
  ): Connection
67
72
  local connections = {} :: { Connection }
68
73
  local prefix = self._name .. "."
@@ -70,8 +75,8 @@ function NamespaceImpl.listenAll(
70
75
 
71
76
  for fullName, packet in self._packets do
72
77
  local shortName = string.sub(fullName, prefixLen + 1)
73
- local conn = packet:listen(function(data: any, sender: Player?): ()
74
- callback(shortName, data, sender)
78
+ local conn = packet:listen(function(data: any, sender: Player?, timestamp: number?): ()
79
+ callback(shortName, data, sender, timestamp)
75
80
  end)
76
81
  table.insert(connections, conn)
77
82
  table.insert(self._tracked, conn)
@@ -4,6 +4,7 @@
4
4
 
5
5
  local RunService = game:GetService("RunService")
6
6
 
7
+ local Channel = require(script.Parent.Parent.internal.Channel)
7
8
  local Client = require(script.Parent.Parent.transport.Client)
8
9
  local Registry = require(script.Parent.Parent.internal.Registry)
9
10
  local Server = require(script.Parent.Parent.transport.Server)
@@ -14,34 +15,44 @@ type Connection = Types.Connection
14
15
  type Codec<T> = Types.Codec<T>
15
16
  type InternalCodec<T> = Types.InternalCodec<T>
16
17
  type PacketConfig<T> = Types.PacketConfig<T>
18
+ type Registration = Types.Registration
17
19
 
18
20
  -- Constants -----------------------------------------------------------
19
21
 
20
22
  local IS_SERVER = RunService:IsServer()
21
23
 
22
- local serverWriteTo = Server.writeTo
23
- local serverWriteToAll = Server.writeToAll
24
- local serverWriteToList = Server.writeToList
25
- local serverWriteToAllExcept = Server.writeToAllExcept
26
- local serverWriteToSet = Server.writeToSet
27
- local clientWrite = Client.write
28
-
29
24
  local DELTA_TARGETED = 1
30
25
  local DELTA_BROADCAST = 2
31
26
 
27
+ local TS_LOOKUP = {
28
+ frame = Channel.TS_FRAME,
29
+ offset = Channel.TS_OFFSET,
30
+ full = Channel.TS_FULL,
31
+ }
32
+
32
33
  -- Private -------------------------------------------------------------
33
34
 
34
35
  type PacketFields = {
35
- _id: number,
36
- _name: string,
37
- _codec: Codec<any>,
36
+ _reg: Registration,
38
37
  _isUnreliable: boolean,
39
38
  _isDelta: boolean,
40
39
  _deltaMode: number,
41
40
  _signal: Signal.Signal,
42
41
  }
43
42
 
44
- -- Server-only metatable. Single send() dispatches on target type.
43
+ local function checkDeltaMode(self: any, mode: number): ()
44
+ if not self._isDelta then
45
+ return
46
+ end
47
+ local current = self._deltaMode
48
+ if current == 0 then
49
+ self._deltaMode = mode
50
+ elseif current ~= mode then
51
+ error(`[Lync] Delta packet cannot mix targeted and broadcast: "{self._reg.name}"`)
52
+ end
53
+ end
54
+
55
+ -- Server-only metatable
45
56
  local ServerImpl = {}
46
57
  ServerImpl.__index = ServerImpl
47
58
 
@@ -63,67 +74,68 @@ function ServerImpl.disconnectAll(self: Packet): ()
63
74
  self._signal:disconnectAll()
64
75
  end
65
76
 
66
- local function checkDeltaMode(self: Packet, mode: number): ()
67
- if not self._isDelta then
68
- return
69
- end
70
- local current = self._deltaMode
71
- if current == 0 then
72
- self._deltaMode = mode
73
- elseif current ~= mode then
74
- error(`[Lync] Delta packet cannot mix targeted and broadcast: "{self._name}"`)
75
- end
76
- end
77
-
78
77
  function ServerImpl.send(self: Packet, data: any, target: any?): ()
79
78
  if target == nil then
80
79
  error("[Lync] Server packet:send requires a target")
81
80
  end
82
81
 
83
- local id = self._id
84
- local name = self._name
85
- local codec = self._codec
82
+ local reg = self._reg
86
83
  local isUnreliable = self._isUnreliable
87
84
 
88
- -- Single player
89
85
  if typeof(target) == "Instance" then
90
86
  checkDeltaMode(self, DELTA_TARGETED)
91
- serverWriteTo(target :: Player, id, name, codec, data, isUnreliable)
87
+ Server.writeTo(target :: Player, reg, data, isUnreliable)
92
88
  return
93
89
  end
94
90
 
95
- -- Must be a table from here
96
91
  local tag = target._tag
97
92
 
98
- -- Lync.all sentinel
99
93
  if tag == "all" then
100
94
  checkDeltaMode(self, DELTA_BROADCAST)
101
- serverWriteToAll(id, name, codec, data, isUnreliable)
95
+ Server.writeToAll(reg, data, isUnreliable)
102
96
  return
103
97
  end
104
98
 
105
- -- Lync.except(player, ...) descriptor
106
99
  if tag == "except" then
107
100
  checkDeltaMode(self, DELTA_BROADCAST)
108
- serverWriteToAllExcept(target._set, id, name, codec, data, isUnreliable)
101
+ Server.writeToAllExcept(target._set, reg, data, isUnreliable)
109
102
  return
110
103
  end
111
104
 
112
- -- Group object
113
105
  if tag == "group" then
114
106
  checkDeltaMode(self, DELTA_BROADCAST)
115
- serverWriteToSet(target:getSet(), id, name, codec, data, isUnreliable)
107
+ Server.writeToSet(target:getSet(), reg, data, isUnreliable)
116
108
  return
117
109
  end
118
110
 
119
- -- Player list(array)
120
111
  checkDeltaMode(self, DELTA_TARGETED)
121
- serverWriteToList(target :: { Player }, id, name, codec, data, isUnreliable)
112
+ Server.writeToList(target :: { Player }, reg, data, isUnreliable)
113
+ end
114
+
115
+ -- Stats: read directly from registration
116
+ function ServerImpl.getBytesSent(self: Packet): number
117
+ return self._reg.bytesSent
118
+ end
119
+
120
+ function ServerImpl.getBytesReceived(self: Packet): number
121
+ return self._reg.bytesReceived
122
+ end
123
+
124
+ function ServerImpl.getFires(self: Packet): number
125
+ return self._reg.fires
126
+ end
127
+
128
+ function ServerImpl.getRecvFires(self: Packet): number
129
+ return self._reg.recvFires
130
+ end
131
+
132
+ function ServerImpl.getDrops(self: Packet): number
133
+ return self._reg.drops
122
134
  end
123
135
 
124
136
  table.freeze(ServerImpl)
125
137
 
126
- -- Client-only metatable. Has send(), no target needed.
138
+ -- Client-only metatable
127
139
  local ClientImpl = {}
128
140
  ClientImpl.__index = ClientImpl
129
141
 
@@ -144,9 +156,15 @@ function ClientImpl.disconnectAll(self: Packet): ()
144
156
  end
145
157
 
146
158
  function ClientImpl.send(self: Packet, data: any): ()
147
- clientWrite(self._id, self._name, self._codec, data, self._isUnreliable)
159
+ Client.write(self._reg, data, self._isUnreliable)
148
160
  end
149
161
 
162
+ ClientImpl.getBytesSent = ServerImpl.getBytesSent
163
+ ClientImpl.getBytesReceived = ServerImpl.getBytesReceived
164
+ ClientImpl.getFires = ServerImpl.getFires
165
+ ClientImpl.getRecvFires = ServerImpl.getRecvFires
166
+ ClientImpl.getDrops = ServerImpl.getDrops
167
+
150
168
  table.freeze(ClientImpl)
151
169
 
152
170
  -- Public --------------------------------------------------------------
@@ -167,6 +185,22 @@ function PacketModule.define(name: string, config: PacketConfig<any>): Packet
167
185
  error(`[Lync] Delta codec requires reliable delivery: "{name}"`)
168
186
  end
169
187
 
188
+ local timestampMode = if config.timestamp then TS_LOOKUP[config.timestamp] else nil
189
+ if config.timestamp and not timestampMode then
190
+ error(`[Lync] Invalid timestamp mode: "{config.timestamp}" on packet "{name}"`)
191
+ end
192
+
193
+ if timestampMode then
194
+ Channel.enableTimestamps()
195
+ end
196
+
197
+ local hasUnknown = (config.value :: any)._hasUnknown
198
+ if hasUnknown and not config.validate then
199
+ warn(
200
+ `[Lync] Packet "{name}" uses Lync.unknown: client data bypasses schema validation. Add a validate callback.`
201
+ )
202
+ end
203
+
170
204
  local signal = Signal.create()
171
205
 
172
206
  local reg = Registry.register(
@@ -176,15 +210,14 @@ function PacketModule.define(name: string, config: PacketConfig<any>): Packet
176
210
  signal,
177
211
  config.rateLimit,
178
212
  config.validate,
179
- config.maxPayloadBytes
213
+ config.maxPayloadBytes,
214
+ timestampMode
180
215
  )
181
216
 
182
217
  local isDelta = (config.value :: InternalCodec<any>)._isDelta == true
183
218
 
184
219
  local fields: PacketFields = {
185
- _id = reg.id,
186
- _name = reg.name,
187
- _codec = reg.codec,
220
+ _reg = reg,
188
221
  _isUnreliable = isUnreliable,
189
222
  _isDelta = isDelta,
190
223
  _deltaMode = 0,
@@ -28,6 +28,7 @@ type PendingQuery = {
28
28
  thread: thread?,
29
29
  callback: ((data: any) -> ())?,
30
30
  timeout: thread?,
31
+ source: Player?,
31
32
  }
32
33
 
33
34
  local _pending = {} :: { [number]: PendingQuery }
@@ -60,12 +61,17 @@ local function releaseCorrelation(): ()
60
61
  _activeCount -= 1
61
62
  end
62
63
 
63
- local function completeQuery(id: number, data: any): ()
64
+ local function completeQuery(id: number, data: any, source: Player?): ()
64
65
  local entry = _pending[id]
65
66
  if not entry then
66
67
  return
67
68
  end
68
69
 
70
+ -- Reject responses from wrong player (prevents correlation spoofing)
71
+ if source ~= nil and entry.source ~= nil and source ~= entry.source then
72
+ return
73
+ end
74
+
69
75
  _pending[id] = nil
70
76
  releaseCorrelation()
71
77
 
@@ -110,7 +116,7 @@ local function requestMulti(self: Query, players: { Player }, data: any): { [Pla
110
116
  if count == 1 then
111
117
  local correlation = allocCorrelation()
112
118
  local thread = coroutine.running()
113
- local entry: PendingQuery = { thread = thread, timeout = nil }
119
+ local entry: PendingQuery = { thread = thread, timeout = nil, source = players[1] }
114
120
  _pending[correlation] = entry
115
121
 
116
122
  Server.writeQuery(
@@ -154,6 +160,7 @@ local function requestMulti(self: Query, players: { Player }, data: any): { [Pla
154
160
  end
155
161
  end,
156
162
  timeout = nil,
163
+ source = player,
157
164
  }
158
165
 
159
166
  _pending[correlation] = entry
@@ -196,12 +203,18 @@ function QueryImpl.listen(self: Query, callback: (...any) -> any): Connection
196
203
  if IS_SERVER then
197
204
  return self._reqReg.signal:connect(
198
205
  function(request: any, player: Player, correlation: number): ()
199
- local ok, response = pcall(callback, request, player)
200
- if not ok then
201
- warn(`[Lync] Query handler error on "{respName}": {response}`)
206
+ local results = table.pack(pcall(callback, request, player))
207
+ if not results[1] then
208
+ warn(`[Lync] Query handler error on "{respName}": {results[2]}`)
209
+ Server.writeQueryNil(player, respId, respName, correlation)
210
+ return
202
211
  end
203
212
 
204
- if ok and response ~= nil then
213
+ local response = if results.n > 2
214
+ then table.move(results, 2, results.n, 1, table.create(results.n - 1))
215
+ else results[2]
216
+
217
+ if response ~= nil then
205
218
  Server.writeQuery(player, respId, respName, correlation, respCodec, response)
206
219
  else
207
220
  Server.writeQueryNil(player, respId, respName, correlation)
@@ -211,12 +224,18 @@ function QueryImpl.listen(self: Query, callback: (...any) -> any): Connection
211
224
  else
212
225
  return self._reqReg.signal:connect(
213
226
  function(request: any, _player: Player?, correlation: number): ()
214
- local ok, response = pcall(callback, request)
215
- if not ok then
216
- warn(`[Lync] Query handler error on "{respName}": {response}`)
227
+ local results = table.pack(pcall(callback, request))
228
+ if not results[1] then
229
+ warn(`[Lync] Query handler error on "{respName}": {results[2]}`)
230
+ Client.writeQueryNil(respId, respName, correlation)
231
+ return
217
232
  end
218
233
 
219
- if ok and response ~= nil then
234
+ local response = if results.n > 2
235
+ then table.move(results, 2, results.n, 1, table.create(results.n - 1))
236
+ else results[2]
237
+
238
+ if response ~= nil then
220
239
  Client.writeQuery(respId, respName, correlation, respCodec, response)
221
240
  else
222
241
  Client.writeQueryNil(respId, respName, correlation)
@@ -251,7 +270,7 @@ function QueryImpl.requestFrom(self: Query, player: Player, data: any): any
251
270
 
252
271
  local correlation = allocCorrelation()
253
272
  local thread = coroutine.running()
254
- local entry: PendingQuery = { thread = thread, timeout = nil }
273
+ local entry: PendingQuery = { thread = thread, timeout = nil, source = player }
255
274
  _pending[correlation] = entry
256
275
 
257
276
  Server.writeQuery(player, self._reqReg.id, self._name, correlation, self._reqReg.codec, data)
@@ -297,18 +316,20 @@ function QueryModule.define(name: string, config: QueryConfig<any, any>): Query
297
316
  local reqSignal = Signal.create()
298
317
  local respSignal = Signal.create()
299
318
 
319
+ local rateLimit = config.rateLimit or { maxPerSecond = 30 }
320
+
300
321
  local reqReg, respReg = Registry.registerQueryPair(
301
322
  name,
302
323
  config.request,
303
324
  config.response,
304
325
  reqSignal,
305
326
  respSignal,
306
- config.rateLimit,
327
+ rateLimit,
307
328
  config.validate
308
329
  )
309
330
 
310
- respSignal:connect(function(resp: any, correlation: number): ()
311
- completeQuery(correlation, resp)
331
+ respSignal:connect(function(resp: any, source: Player?, correlation: number): ()
332
+ completeQuery(correlation, resp, source)
312
333
  end)
313
334
 
314
335
  return setmetatable({
@@ -91,6 +91,10 @@ SignalImpl.__index = SignalImpl
91
91
  export type Signal = typeof(setmetatable({} :: SignalFields, SignalImpl))
92
92
 
93
93
  local function addEntry(self: Signal, callback: (...any) -> (), isOnce: boolean): Connection
94
+ if type(callback) ~= "function" then
95
+ error("[Lync] Expected function, got " .. type(callback))
96
+ end
97
+
94
98
  local count = self._count + 1
95
99
  self._count = count
96
100