erlc-v2 1.0.0-beta.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.
@@ -0,0 +1,312 @@
1
+ const {
2
+ diffByKey,
3
+ getPlayerKey,
4
+ getVehicleKey,
5
+ areEqual,
6
+ buildLogSignature,
7
+ parseLogCommand,
8
+ } = require("./diff");
9
+
10
+ class SeenSignatures {
11
+ constructor({ ttlMs = 0, maxEntries = 5000 } = {}) {
12
+ this.ttlMs = ttlMs;
13
+ this.maxEntries = maxEntries;
14
+ this.map = new Map();
15
+ }
16
+
17
+ _prune() {
18
+ if (this.ttlMs > 0) {
19
+ const now = Date.now();
20
+ for (const [key, expiresAt] of this.map.entries()) {
21
+ if (expiresAt <= now) this.map.delete(key);
22
+ }
23
+ }
24
+ while (this.map.size > this.maxEntries) {
25
+ const oldest = this.map.keys().next().value;
26
+ this.map.delete(oldest);
27
+ }
28
+ }
29
+
30
+ seed(signature) {
31
+ this._prune();
32
+ this.map.set(signature, this.ttlMs > 0 ? Date.now() + this.ttlMs : 0);
33
+ }
34
+
35
+ checkAndAdd(signature) {
36
+ this._prune();
37
+ if (this.map.has(signature)) {
38
+ return false;
39
+ }
40
+ this.map.set(signature, this.ttlMs > 0 ? Date.now() + this.ttlMs : 0);
41
+ return true;
42
+ }
43
+ }
44
+
45
+ class Poller {
46
+ constructor(client, options = {}) {
47
+ this.client = client;
48
+ this.intervalMs = Math.max(250, options.intervalMs ?? 2500);
49
+ this.enabled = options.enabled !== false;
50
+ this.bypassCache = options.bypassCache !== false;
51
+ this.running = false;
52
+ this.inFlight = false;
53
+ this.timer = null;
54
+ this.readyEmitted = false;
55
+ this.previous = null;
56
+ this.seen = {
57
+ kills: new SeenSignatures(),
58
+ joins: new SeenSignatures(),
59
+ commands: new SeenSignatures(),
60
+ modCalls: new SeenSignatures(),
61
+ };
62
+ }
63
+
64
+ start() {
65
+ if (!this.enabled || this.running) return;
66
+ this.running = true;
67
+ this._schedule(0);
68
+ }
69
+
70
+ stop() {
71
+ this.running = false;
72
+ if (this.timer) {
73
+ clearTimeout(this.timer);
74
+ this.timer = null;
75
+ }
76
+ }
77
+
78
+ destroy() {
79
+ this.stop();
80
+ this.previous = null;
81
+ }
82
+
83
+ _schedule(waitMs) {
84
+ if (!this.running) return;
85
+ if (this.timer) clearTimeout(this.timer);
86
+ this.timer = setTimeout(() => {
87
+ this.timer = null;
88
+ void this._tick();
89
+ }, waitMs);
90
+ }
91
+
92
+ _primeSeen(snapshot) {
93
+ for (const item of snapshot.killLogs)
94
+ this.seen.kills.seed(buildLogSignature("kills", item));
95
+ for (const item of snapshot.joinLogs)
96
+ this.seen.joins.seed(buildLogSignature("joins", item));
97
+ for (const item of snapshot.commandLogs)
98
+ this.seen.commands.seed(buildLogSignature("commands", item));
99
+ for (const item of snapshot.modCalls)
100
+ this.seen.modCalls.seed(buildLogSignature("modCalls", item));
101
+ }
102
+
103
+ _emitServerUpdate(current) {
104
+ const prevSummary = this.previous
105
+ ? {
106
+ name: this.previous.name,
107
+ ownerId: this.previous.ownerId,
108
+ currentPlayers: this.previous.currentPlayers,
109
+ maxPlayers: this.previous.maxPlayers,
110
+ teamBalance: this.previous.teamBalance,
111
+ accVerifiedReq: this.previous.accVerifiedReq,
112
+ }
113
+ : null;
114
+ const currentSummary = {
115
+ name: current.name,
116
+ ownerId: current.ownerId,
117
+ currentPlayers: current.currentPlayers,
118
+ maxPlayers: current.maxPlayers,
119
+ teamBalance: current.teamBalance,
120
+ accVerifiedReq: current.accVerifiedReq,
121
+ };
122
+ if (!prevSummary || !areEqual(prevSummary, currentSummary)) {
123
+ this.client.emit("serverUpdate", {
124
+ previous: prevSummary,
125
+ current: currentSummary,
126
+ });
127
+ }
128
+ }
129
+
130
+ _emitPlayerEvents(previous, current) {
131
+ const hasPlayersData = Array.isArray(current.raw?.Players);
132
+ if (hasPlayersData) {
133
+ const diff = diffByKey(previous.players, current.players, getPlayerKey);
134
+ for (const player of diff.added) {
135
+ this.client.emit("playerJoin", {
136
+ player,
137
+ source: "players",
138
+ snapshot: current,
139
+ });
140
+ }
141
+ for (const player of diff.removed) {
142
+ this.client.emit("playerLeave", {
143
+ player,
144
+ source: "players",
145
+ snapshot: current,
146
+ });
147
+ }
148
+ return;
149
+ }
150
+
151
+ for (const entry of current.joinLogs) {
152
+ const signature = buildLogSignature("joins", entry);
153
+ if (!this.seen.joins.checkAndAdd(signature)) continue;
154
+ if (entry?.Join === true) {
155
+ this.client.emit("playerJoin", {
156
+ player: entry.Player,
157
+ log: entry,
158
+ source: "joinLogs",
159
+ snapshot: current,
160
+ });
161
+ } else if (entry?.Join === false) {
162
+ this.client.emit("playerLeave", {
163
+ player: entry.Player,
164
+ log: entry,
165
+ source: "joinLogs",
166
+ snapshot: current,
167
+ });
168
+ }
169
+ }
170
+ }
171
+
172
+ _emitVehicleEvents(previous, current) {
173
+ const diff = diffByKey(previous.vehicles, current.vehicles, getVehicleKey);
174
+ for (const vehicle of diff.added) {
175
+ this.client.emit("vehicleSpawn", {
176
+ vehicle,
177
+ snapshot: current,
178
+ });
179
+ }
180
+ for (const vehicle of diff.removed) {
181
+ this.client.emit("vehicleDespawn", {
182
+ vehicle,
183
+ snapshot: current,
184
+ });
185
+ }
186
+ }
187
+
188
+ _emitQueueAndStaff(previous, current) {
189
+ if (!areEqual(previous.queue, current.queue)) {
190
+ this.client.emit("queueUpdate", {
191
+ previous: previous.queue,
192
+ current: current.queue,
193
+ snapshot: current,
194
+ });
195
+ }
196
+ if (!areEqual(previous.staff, current.staff)) {
197
+ this.client.emit("staffUpdate", {
198
+ previous: previous.staff,
199
+ current: current.staff,
200
+ snapshot: current,
201
+ });
202
+ }
203
+ }
204
+
205
+ _emitLogEvents(current) {
206
+ for (const kill of current.killLogs) {
207
+ const signature = buildLogSignature("kills", kill);
208
+ if (!this.seen.kills.checkAndAdd(signature)) continue;
209
+ this.client.emit("kill", {
210
+ kill,
211
+ signature,
212
+ snapshot: current,
213
+ });
214
+ }
215
+
216
+ for (const modCall of current.modCalls) {
217
+ const signature = buildLogSignature("modCalls", modCall);
218
+ if (!this.seen.modCalls.checkAndAdd(signature)) continue;
219
+ this.client.emit("modCall", {
220
+ modCall,
221
+ signature,
222
+ snapshot: current,
223
+ });
224
+ }
225
+
226
+ for (const command of current.commandLogs) {
227
+ const signature = buildLogSignature("commands", command);
228
+ if (!this.seen.commands.checkAndAdd(signature)) continue;
229
+ const payload = {
230
+ command,
231
+ signature,
232
+ snapshot: current,
233
+ };
234
+ this.client.emit("commandLog", payload);
235
+
236
+ const parsedLogCommand = parseLogCommand(command);
237
+ if (parsedLogCommand) {
238
+ this.client.emit("logCommand", {
239
+ ...payload,
240
+ parsed: parsedLogCommand,
241
+ });
242
+ }
243
+ }
244
+ }
245
+
246
+ _handleSnapshot(current) {
247
+ if (!this.previous) {
248
+ this._primeSeen(current);
249
+ this.previous = current;
250
+ if (!this.readyEmitted) {
251
+ this.readyEmitted = true;
252
+ this.client.emit("ready");
253
+ }
254
+ return;
255
+ }
256
+
257
+ this._emitServerUpdate(current);
258
+ this._emitPlayerEvents(this.previous, current);
259
+ this._emitVehicleEvents(this.previous, current);
260
+ this._emitQueueAndStaff(this.previous, current);
261
+ this._emitLogEvents(current);
262
+ this.previous = current;
263
+ }
264
+
265
+ async _tick() {
266
+ if (!this.running || this.inFlight) return;
267
+ this.inFlight = true;
268
+ this.client.logger.debug({
269
+ msg: "poll_cycle_start",
270
+ intervalMs: this.intervalMs,
271
+ });
272
+
273
+ try {
274
+ const snapshot = await this.client.server.fetch(
275
+ {
276
+ players: true,
277
+ staff: true,
278
+ joinLogs: true,
279
+ queue: true,
280
+ killLogs: true,
281
+ commandLogs: true,
282
+ modCalls: true,
283
+ vehicles: true,
284
+ },
285
+ {
286
+ bypassCache: this.bypassCache,
287
+ dedupe: false,
288
+ },
289
+ );
290
+
291
+ this._handleSnapshot(snapshot);
292
+ } catch (error) {
293
+ if (this.running) {
294
+ if (this.client.listenerCount("error") > 0) {
295
+ this.client.emit("error", error);
296
+ } else {
297
+ this.client.logger.error({
298
+ msg: "poll_cycle_error",
299
+ error: error?.message,
300
+ });
301
+ }
302
+ }
303
+ } finally {
304
+ this.inFlight = false;
305
+ if (this.running) {
306
+ this._schedule(this.intervalMs);
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ module.exports = Poller;
@@ -0,0 +1,119 @@
1
+ const { stableStringify } = require("../util/stableStringify");
2
+
3
+ function getPlayerKey(player) {
4
+ if (!player) return "";
5
+ if (typeof player === "string") return player;
6
+ return String(player.Player ?? player.Name ?? "");
7
+ }
8
+
9
+ function getVehicleKey(vehicle) {
10
+ if (!vehicle) return "";
11
+ const name = vehicle.Name ?? "";
12
+ const owner = vehicle.Owner ?? "";
13
+ const texture = vehicle.Texture ?? "";
14
+ const colorHex = vehicle.ColorHex ?? "";
15
+ const colorName = vehicle.ColorName ?? "";
16
+ return `${name}|${owner}|${texture}|${colorHex}|${colorName}`;
17
+ }
18
+
19
+ function diffByKey(previous, current, keyFn) {
20
+ const prevList = Array.isArray(previous) ? previous : [];
21
+ const currList = Array.isArray(current) ? current : [];
22
+
23
+ const prevMap = new Map();
24
+ for (const item of prevList) {
25
+ prevMap.set(keyFn(item), item);
26
+ }
27
+
28
+ const currMap = new Map();
29
+ for (const item of currList) {
30
+ currMap.set(keyFn(item), item);
31
+ }
32
+
33
+ const added = [];
34
+ const removed = [];
35
+
36
+ for (const [key, value] of currMap.entries()) {
37
+ if (!prevMap.has(key)) added.push(value);
38
+ }
39
+ for (const [key, value] of prevMap.entries()) {
40
+ if (!currMap.has(key)) removed.push(value);
41
+ }
42
+
43
+ return { added, removed };
44
+ }
45
+
46
+ function areEqual(a, b) {
47
+ return stableStringify(a) === stableStringify(b);
48
+ }
49
+
50
+ function buildLogSignature(type, item) {
51
+ switch (type) {
52
+ case "kills":
53
+ return stableStringify({
54
+ t: item?.Timestamp ?? null,
55
+ killer: item?.Killer ?? "",
56
+ killed: item?.Killed ?? "",
57
+ weapon: item?.Weapon ?? "",
58
+ });
59
+ case "joins":
60
+ return stableStringify({
61
+ t: item?.Timestamp ?? null,
62
+ player: item?.Player ?? "",
63
+ join: item?.Join ?? null,
64
+ });
65
+ case "commands":
66
+ return stableStringify({
67
+ t: item?.Timestamp ?? null,
68
+ player: item?.Player ?? "",
69
+ command: item?.Command ?? "",
70
+ });
71
+ case "modCalls":
72
+ return stableStringify({
73
+ t: item?.Timestamp ?? null,
74
+ caller: item?.Caller ?? "",
75
+ moderator: item?.Moderator ?? "",
76
+ });
77
+ default:
78
+ return stableStringify(item);
79
+ }
80
+ }
81
+
82
+ function parseCommand(commandLogEntry) {
83
+ const raw = String(commandLogEntry?.Command ?? "").trim();
84
+ if (!raw) return null;
85
+
86
+ const segments = raw.split(/\s+/);
87
+ const command = String(segments[0] ?? "").toLowerCase();
88
+ const args = segments.slice(1).join(" ");
89
+
90
+ return {
91
+ raw,
92
+ command,
93
+ args,
94
+ segments,
95
+ };
96
+ }
97
+
98
+ function parseLogCommand(commandLogEntry) {
99
+ const parsed = parseCommand(commandLogEntry);
100
+ if (!parsed || parsed.command !== ":log") return null;
101
+
102
+ const segments = parsed.segments.slice(1);
103
+ return {
104
+ raw: parsed.raw,
105
+ keyword: segments[0] ?? "",
106
+ args: segments.slice(1).join(" "),
107
+ segments,
108
+ };
109
+ }
110
+
111
+ module.exports = {
112
+ getPlayerKey,
113
+ getVehicleKey,
114
+ diffByKey,
115
+ areEqual,
116
+ buildLogSignature,
117
+ parseCommand,
118
+ parseLogCommand,
119
+ };
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ const Client = require("./Client");
2
+ const errors = require("./errors");
3
+
4
+ module.exports = {
5
+ Client,
6
+ ...errors,
7
+ };
@@ -0,0 +1,163 @@
1
+ class RateLimiter {
2
+ constructor(options = {}, logger) {
3
+ this.enabled = options.enabled !== false;
4
+ this.strictSerial = options.strictSerial !== false;
5
+ this.perBucketConcurrency = this.strictSerial
6
+ ? 1
7
+ : Math.max(1, options.perBucketConcurrency ?? 1);
8
+ this.globalConcurrency = this.strictSerial
9
+ ? 1
10
+ : Math.max(1, options.globalConcurrency ?? 4);
11
+ this.logger = logger;
12
+ this.buckets = new Map();
13
+ this.globalActive = 0;
14
+ this.destroyed = false;
15
+ }
16
+
17
+ _createState(bucketId) {
18
+ return {
19
+ id: bucketId,
20
+ limit: Infinity,
21
+ remaining: Infinity,
22
+ resetEpochMs: 0,
23
+ blockedUntilMs: 0,
24
+ active: 0,
25
+ queue: [],
26
+ timer: null,
27
+ };
28
+ }
29
+
30
+ _getState(bucketId) {
31
+ if (!this.buckets.has(bucketId)) {
32
+ this.buckets.set(bucketId, this._createState(bucketId));
33
+ }
34
+ return this.buckets.get(bucketId);
35
+ }
36
+
37
+ _clearTimer(state) {
38
+ if (state.timer) {
39
+ clearTimeout(state.timer);
40
+ state.timer = null;
41
+ }
42
+ }
43
+
44
+ _scheduleTimer(state, waitMs) {
45
+ this._clearTimer(state);
46
+ state.timer = setTimeout(() => {
47
+ state.timer = null;
48
+ this._drain(state.id);
49
+ }, waitMs);
50
+ }
51
+
52
+ _canRun(state) {
53
+ if (!this.enabled) return true;
54
+ if (this.destroyed) return false;
55
+ if (state.active >= this.perBucketConcurrency) return false;
56
+ if (this.globalActive >= this.globalConcurrency) return false;
57
+
58
+ const now = Date.now();
59
+ const resetBlock =
60
+ Number.isFinite(state.remaining) && state.remaining <= 0
61
+ ? state.resetEpochMs
62
+ : 0;
63
+ const blockedUntil = Math.max(state.blockedUntilMs || 0, resetBlock || 0);
64
+ if (blockedUntil > now) {
65
+ this.logger.debug({
66
+ msg: "rate_limit_wait",
67
+ bucket: state.id,
68
+ waitMs: blockedUntil - now,
69
+ });
70
+ this._scheduleTimer(state, Math.max(10, blockedUntil - now));
71
+ return false;
72
+ }
73
+ return true;
74
+ }
75
+
76
+ _drain(bucketId) {
77
+ const state = this._getState(bucketId);
78
+ if (!state.queue.length) return;
79
+ if (!this._canRun(state)) return;
80
+
81
+ const job = state.queue.shift();
82
+ state.active += 1;
83
+ this.globalActive += 1;
84
+
85
+ Promise.resolve()
86
+ .then(job.run)
87
+ .then(job.resolve, job.reject)
88
+ .finally(() => {
89
+ state.active -= 1;
90
+ this.globalActive -= 1;
91
+ this._drain(bucketId);
92
+ });
93
+ }
94
+
95
+ schedule(bucketId, run) {
96
+ if (this.destroyed) {
97
+ return Promise.reject(new Error("RateLimiter destroyed"));
98
+ }
99
+ const state = this._getState(bucketId || "global");
100
+ return new Promise((resolve, reject) => {
101
+ state.queue.push({ run, resolve, reject });
102
+ this._drain(state.id);
103
+ });
104
+ }
105
+
106
+ update(bucketId, headers = {}) {
107
+ const state = this._getState(bucketId || "global");
108
+ const limit = headers.limit != null ? Number(headers.limit) : Number.NaN;
109
+ const remaining =
110
+ headers.remaining != null ? Number(headers.remaining) : Number.NaN;
111
+ const resetEpochMs =
112
+ headers.resetEpochMs != null ? Number(headers.resetEpochMs) : Number.NaN;
113
+
114
+ if (!Number.isNaN(limit) && Number.isFinite(limit)) state.limit = limit;
115
+ if (!Number.isNaN(remaining) && Number.isFinite(remaining))
116
+ state.remaining = remaining;
117
+ if (!Number.isNaN(resetEpochMs) && Number.isFinite(resetEpochMs))
118
+ state.resetEpochMs = resetEpochMs;
119
+
120
+ if (state.remaining <= 0 && state.resetEpochMs > Date.now()) {
121
+ state.blockedUntilMs = Math.max(
122
+ state.blockedUntilMs || 0,
123
+ state.resetEpochMs,
124
+ );
125
+ }
126
+ this._drain(state.id);
127
+ }
128
+
129
+ block(bucketId, untilMs) {
130
+ const state = this._getState(bucketId || "global");
131
+ state.blockedUntilMs = Math.max(state.blockedUntilMs || 0, untilMs);
132
+ if (state.blockedUntilMs > Date.now()) {
133
+ const waitMs = state.blockedUntilMs - Date.now();
134
+ this.logger.debug({
135
+ msg: "rate_limit_block",
136
+ bucket: state.id,
137
+ waitMs,
138
+ });
139
+ this._scheduleTimer(state, Math.max(10, waitMs));
140
+ }
141
+ }
142
+
143
+ clearBucket(bucketId) {
144
+ const state = this.buckets.get(bucketId);
145
+ if (!state) return;
146
+ this._clearTimer(state);
147
+ this.buckets.delete(bucketId);
148
+ }
149
+
150
+ destroy(error) {
151
+ this.destroyed = true;
152
+ for (const state of this.buckets.values()) {
153
+ this._clearTimer(state);
154
+ while (state.queue.length) {
155
+ const job = state.queue.shift();
156
+ job.reject(error || new Error("RateLimiter destroyed"));
157
+ }
158
+ }
159
+ this.buckets.clear();
160
+ }
161
+ }
162
+
163
+ module.exports = RateLimiter;