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,306 @@
1
+ const RateLimiter = require("./RateLimiter");
2
+ const { createApiError, parseErrorCode } = require("../util/errors");
3
+ const { ERLCHttpError, KeyExpiredError, KeyBannedError } = require("../errors");
4
+ const { stableStringify } = require("../util/stableStringify");
5
+
6
+ function parseResetHeader(value) {
7
+ const parsed = Number(value);
8
+ if (!Number.isFinite(parsed)) return null;
9
+ if (parsed < 1e12) return parsed * 1000;
10
+ return parsed;
11
+ }
12
+
13
+ function parseRetryAfterMs(body, headers) {
14
+ const retryRaw = body?.retry_after ?? body?.retryAfter ?? body?.RetryAfter;
15
+ if (
16
+ retryRaw !== undefined &&
17
+ retryRaw !== null &&
18
+ !Number.isNaN(Number(retryRaw))
19
+ ) {
20
+ return Math.max(0, Number(retryRaw) * 1000);
21
+ }
22
+ const resetEpochMs = parseResetHeader(headers.get("x-ratelimit-reset"));
23
+ if (resetEpochMs) {
24
+ return Math.max(0, resetEpochMs - Date.now());
25
+ }
26
+ return 0;
27
+ }
28
+
29
+ function parseJsonBody(text) {
30
+ if (!text || typeof text !== "string") return null;
31
+ try {
32
+ return JSON.parse(text);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function buildQueryString(query = {}) {
39
+ const params = new URLSearchParams();
40
+ const keys = Object.keys(query).sort();
41
+ for (const key of keys) {
42
+ const value = query[key];
43
+ if (value === undefined || value === null) continue;
44
+ if (typeof value === "boolean") {
45
+ params.set(key, String(value));
46
+ continue;
47
+ }
48
+ params.set(key, String(value));
49
+ }
50
+ return params.toString();
51
+ }
52
+
53
+ class RequestManager {
54
+ constructor(options) {
55
+ this.baseURL = options.baseURL;
56
+ this.serverKey = options.serverKey;
57
+ this.globalKey = options.globalKey;
58
+ this.cache = options.cache;
59
+ this.logger = options.logger;
60
+ this.rateLimiter = new RateLimiter(options.rateLimit || {}, this.logger);
61
+ this.routeBuckets = new Map();
62
+ this.inflight = new Map();
63
+ this.terminalError = null;
64
+ this.disconnected = false;
65
+ this.onDisconnect = options.onDisconnect;
66
+ this.unauthorizedThreshold = options.rateLimit?.unauthorizedThreshold ?? 3;
67
+ this.consecutiveForbidden = 0;
68
+ this.destroyed = false;
69
+ }
70
+
71
+ _getRouteKey(method, path) {
72
+ return `${method.toUpperCase()} ${path}`;
73
+ }
74
+
75
+ _buildCacheKey(method, path, query, body) {
76
+ const headerSubset = {
77
+ "Server-Key": this.serverKey,
78
+ Authorization: this.globalKey || "",
79
+ };
80
+ return stableStringify({
81
+ method: method.toUpperCase(),
82
+ path,
83
+ query,
84
+ body,
85
+ headers: headerSubset,
86
+ });
87
+ }
88
+
89
+ _setTerminal(error, reason) {
90
+ if (this.disconnected) return;
91
+ this.disconnected = true;
92
+ this.terminalError = error;
93
+ this.rateLimiter.destroy(error);
94
+ if (typeof this.onDisconnect === "function") {
95
+ this.onDisconnect(reason, error);
96
+ }
97
+ }
98
+
99
+ _evaluateDisconnect(error) {
100
+ if (error instanceof KeyExpiredError) {
101
+ this._setTerminal(error, "key_expired");
102
+ return;
103
+ }
104
+ if (error instanceof KeyBannedError) {
105
+ this._setTerminal(error, "key_banned");
106
+ return;
107
+ }
108
+
109
+ if (error.status === 403) {
110
+ this.consecutiveForbidden += 1;
111
+ if (this.consecutiveForbidden >= this.unauthorizedThreshold) {
112
+ this._setTerminal(error, "unauthorized");
113
+ }
114
+ return;
115
+ }
116
+
117
+ this.consecutiveForbidden = 0;
118
+ }
119
+
120
+ async _send(method, path, query, body) {
121
+ const routeKey = this._getRouteKey(method, path);
122
+ const currentBucket = this.routeBuckets.get(routeKey) || "global";
123
+ const queryString = buildQueryString(query);
124
+ const endpoint = queryString ? `${path}?${queryString}` : path;
125
+ const url = `${this.baseURL}${endpoint}`;
126
+
127
+ const result = await this.rateLimiter.schedule(currentBucket, async () => {
128
+ this.logger.debug({
129
+ msg: "request_start",
130
+ method,
131
+ route: path,
132
+ bucket: currentBucket,
133
+ });
134
+
135
+ let response;
136
+ try {
137
+ const headers = {
138
+ "Server-Key": this.serverKey,
139
+ ...(this.globalKey ? { Authorization: this.globalKey } : {}),
140
+ };
141
+ const hasBody = body !== undefined;
142
+ if (hasBody) {
143
+ headers["Content-Type"] = "application/json";
144
+ }
145
+
146
+ response = await fetch(url, {
147
+ method,
148
+ headers,
149
+ ...(hasBody ? { body: JSON.stringify(body) } : {}),
150
+ });
151
+ } catch (networkErr) {
152
+ throw new ERLCHttpError(
153
+ `Network error for ${endpoint}: ${networkErr?.message || "unknown"}`,
154
+ {
155
+ status: null,
156
+ endpoint,
157
+ bucket: currentBucket,
158
+ body: null,
159
+ },
160
+ );
161
+ }
162
+
163
+ const bucket =
164
+ response.headers.get("x-ratelimit-bucket") || currentBucket || "global";
165
+ const limit = Number(response.headers.get("x-ratelimit-limit"));
166
+ const remaining = Number(response.headers.get("x-ratelimit-remaining"));
167
+ const resetEpochMs = parseResetHeader(
168
+ response.headers.get("x-ratelimit-reset"),
169
+ );
170
+
171
+ this.routeBuckets.set(routeKey, bucket);
172
+ this.rateLimiter.update(bucket, { limit, remaining, resetEpochMs });
173
+
174
+ const text = await response.text();
175
+ const parsedBody = parseJsonBody(text);
176
+
177
+ if (response.ok) {
178
+ this.logger.debug({
179
+ msg: "request_success",
180
+ method,
181
+ route: path,
182
+ bucket,
183
+ status: response.status,
184
+ });
185
+ return {
186
+ status: response.status,
187
+ data: parsedBody ?? {},
188
+ endpoint,
189
+ bucket,
190
+ rateLimit: { limit, remaining, resetEpochMs },
191
+ };
192
+ }
193
+
194
+ const retryAfterMs = parseRetryAfterMs(parsedBody, response.headers);
195
+ if (response.status === 429) {
196
+ const blockUntil = Date.now() + retryAfterMs;
197
+ this.rateLimiter.block(bucket, Math.max(blockUntil, resetEpochMs || 0));
198
+ this.logger.warn({
199
+ msg: "request_rate_limited",
200
+ route: path,
201
+ bucket,
202
+ retryAfterMs,
203
+ });
204
+ }
205
+
206
+ const apiError = createApiError({
207
+ status: response.status,
208
+ endpoint,
209
+ bucket,
210
+ body: parsedBody,
211
+ retryAfterMs,
212
+ resetEpochMs,
213
+ });
214
+
215
+ throw apiError;
216
+ });
217
+
218
+ return result;
219
+ }
220
+
221
+ async request({
222
+ method = "GET",
223
+ path,
224
+ query = {},
225
+ body,
226
+ useCache = true,
227
+ cacheTtlMs,
228
+ dedupe = true,
229
+ }) {
230
+ if (this.destroyed) {
231
+ throw this.terminalError || new ERLCHttpError("Client destroyed");
232
+ }
233
+ if (this.terminalError) {
234
+ throw this.terminalError;
235
+ }
236
+
237
+ const normalizedMethod = method.toUpperCase();
238
+ const cacheKey = this._buildCacheKey(normalizedMethod, path, query, body);
239
+
240
+ if (normalizedMethod === "GET" && useCache && this.cache?.enabled) {
241
+ const cached = this.cache.get(cacheKey);
242
+ if (cached) {
243
+ this.logger.debug({ msg: "cache_hit", path });
244
+ return cached;
245
+ }
246
+ }
247
+
248
+ if (dedupe && this.inflight.has(cacheKey)) {
249
+ this.logger.debug({ msg: "request_deduped", path });
250
+ return this.inflight.get(cacheKey);
251
+ }
252
+
253
+ const run = this._send(normalizedMethod, path, query, body)
254
+ .then((result) => {
255
+ if (normalizedMethod === "GET" && useCache && this.cache?.enabled) {
256
+ this.cache.set(cacheKey, result, cacheTtlMs);
257
+ }
258
+ this.consecutiveForbidden = 0;
259
+ return result;
260
+ })
261
+ .catch((error) => {
262
+ const status = error?.status ?? null;
263
+ if (
264
+ status === null &&
265
+ parseErrorCode(error?.body) === 0 &&
266
+ !error.endpoint
267
+ ) {
268
+ const wrapped = new ERLCHttpError(
269
+ error.message || "Unknown request error",
270
+ {
271
+ status,
272
+ endpoint: path,
273
+ bucket:
274
+ this.routeBuckets.get(
275
+ this._getRouteKey(normalizedMethod, path),
276
+ ) || "global",
277
+ body: null,
278
+ },
279
+ );
280
+ this._evaluateDisconnect(wrapped);
281
+ throw wrapped;
282
+ }
283
+
284
+ this._evaluateDisconnect(error);
285
+ throw error;
286
+ })
287
+ .finally(() => {
288
+ this.inflight.delete(cacheKey);
289
+ });
290
+
291
+ if (dedupe) {
292
+ this.inflight.set(cacheKey, run);
293
+ }
294
+
295
+ return run;
296
+ }
297
+
298
+ destroy(reasonError) {
299
+ this.destroyed = true;
300
+ this.terminalError = reasonError || this.terminalError;
301
+ this.rateLimiter.destroy(reasonError);
302
+ this.inflight.clear();
303
+ }
304
+ }
305
+
306
+ module.exports = RequestManager;
@@ -0,0 +1,55 @@
1
+ const API_ERROR_MESSAGES = {
2
+ 0: "Unknown API error",
3
+ 1001: "Error communicating with Roblox/private server",
4
+ 1002: "Internal system error",
5
+ 2000: "Missing server-key",
6
+ 2001: "Invalid server-key format",
7
+ 2002: "Invalid or expired server-key",
8
+ 2003: "Invalid global API key",
9
+ 2004: "Server-key banned",
10
+ 3002: "Server offline (no players)",
11
+ 4001: "Rate limited",
12
+ 9998: "Restricted resource",
13
+ 9999: "Module out of date",
14
+ };
15
+
16
+ const QUERY_FLAG_MAP = {
17
+ players: "Players",
18
+ staff: "Staff",
19
+ joinLogs: "JoinLogs",
20
+ queue: "Queue",
21
+ killLogs: "KillLogs",
22
+ commandLogs: "CommandLogs",
23
+ modCalls: "ModCalls",
24
+ vehicles: "Vehicles",
25
+ };
26
+
27
+ const DEFAULT_OPTIONS = {
28
+ serverKey: "",
29
+ globalKey: "",
30
+ logging: false,
31
+ logger: null,
32
+ cache: {
33
+ enabled: true,
34
+ ttlMs: 1500,
35
+ maxSize: 500,
36
+ },
37
+ rateLimit: {
38
+ enabled: true,
39
+ strictSerial: true,
40
+ perBucketConcurrency: 1,
41
+ globalConcurrency: 1,
42
+ unauthorizedThreshold: 3,
43
+ },
44
+ polling: {
45
+ enabled: true,
46
+ intervalMs: 2500,
47
+ bypassCache: true,
48
+ },
49
+ };
50
+
51
+ module.exports = {
52
+ API_ERROR_MESSAGES,
53
+ QUERY_FLAG_MAP,
54
+ DEFAULT_OPTIONS,
55
+ };
@@ -0,0 +1,79 @@
1
+ const { API_ERROR_MESSAGES } = require("./constants");
2
+ const {
3
+ ERLCHttpError,
4
+ ERLCAPIError,
5
+ RateLimitError,
6
+ KeyExpiredError,
7
+ KeyBannedError,
8
+ InvalidGlobalKeyError,
9
+ ServerOfflineError,
10
+ RestrictedError,
11
+ ModuleOutOfDateError,
12
+ } = require("../errors");
13
+
14
+ function parseErrorCode(body) {
15
+ const value = body?.ErrorCode ?? body?.errorCode ?? body?.Code ?? body?.code;
16
+ if (value === undefined || value === null || Number.isNaN(Number(value))) {
17
+ return 0;
18
+ }
19
+ return Number(value);
20
+ }
21
+
22
+ function parseErrorMessage(body, fallbackCode = 0) {
23
+ const message = body?.Message ?? body?.message ?? body?.error ?? body?.Error;
24
+ if (typeof message === "string" && message.trim()) {
25
+ return message;
26
+ }
27
+ return API_ERROR_MESSAGES[fallbackCode] ?? API_ERROR_MESSAGES[0];
28
+ }
29
+
30
+ function createApiError({
31
+ status,
32
+ endpoint,
33
+ bucket,
34
+ body,
35
+ retryAfterMs = 0,
36
+ resetEpochMs = null,
37
+ }) {
38
+ const errorCode = parseErrorCode(body);
39
+ const message = parseErrorMessage(body, errorCode);
40
+ const common = {
41
+ status,
42
+ endpoint,
43
+ bucket,
44
+ body,
45
+ errorCode,
46
+ apiMessage: message,
47
+ };
48
+
49
+ if (status === 429 || errorCode === 4001) {
50
+ return new RateLimitError(message, {
51
+ ...common,
52
+ retryAfterMs,
53
+ resetEpochMs,
54
+ });
55
+ }
56
+
57
+ if (errorCode === 2002) return new KeyExpiredError(message, common);
58
+ if (errorCode === 2004) return new KeyBannedError(message, common);
59
+ if (errorCode === 2003) return new InvalidGlobalKeyError(message, common);
60
+ if (errorCode === 3002) return new ServerOfflineError(message, common);
61
+ if (errorCode === 9998) return new RestrictedError(message, common);
62
+ if (errorCode === 9999) return new ModuleOutOfDateError(message, common);
63
+
64
+ if (errorCode !== 0) {
65
+ return new ERLCAPIError(message, common);
66
+ }
67
+
68
+ return new ERLCHttpError(`HTTP ${status}`, {
69
+ status,
70
+ endpoint,
71
+ bucket,
72
+ body,
73
+ });
74
+ }
75
+
76
+ module.exports = {
77
+ createApiError,
78
+ parseErrorCode,
79
+ };
@@ -0,0 +1,39 @@
1
+ const NOOP = () => {};
2
+
3
+ function createLogger(logging, customLogger) {
4
+ if (!logging) {
5
+ return { info: NOOP, warn: NOOP, error: NOOP, debug: NOOP };
6
+ }
7
+
8
+ if (customLogger) {
9
+ return {
10
+ info:
11
+ typeof customLogger.info === "function"
12
+ ? customLogger.info.bind(customLogger)
13
+ : NOOP,
14
+ warn:
15
+ typeof customLogger.warn === "function"
16
+ ? customLogger.warn.bind(customLogger)
17
+ : NOOP,
18
+ error:
19
+ typeof customLogger.error === "function"
20
+ ? customLogger.error.bind(customLogger)
21
+ : NOOP,
22
+ debug:
23
+ typeof customLogger.debug === "function"
24
+ ? customLogger.debug.bind(customLogger)
25
+ : NOOP,
26
+ };
27
+ }
28
+
29
+ return {
30
+ info: (...args) => console.info(...args),
31
+ warn: (...args) => console.warn(...args),
32
+ error: (...args) => console.error(...args),
33
+ debug: (...args) => console.debug(...args),
34
+ };
35
+ }
36
+
37
+ module.exports = {
38
+ createLogger,
39
+ };
@@ -0,0 +1,25 @@
1
+ function normalizeServerResponse(raw = {}) {
2
+ return {
3
+ name: raw.Name ?? null,
4
+ ownerId: raw.OwnerId ?? null,
5
+ coOwnerIds: Array.isArray(raw.CoOwnerIds) ? raw.CoOwnerIds : [],
6
+ currentPlayers: raw.CurrentPlayers ?? 0,
7
+ maxPlayers: raw.MaxPlayers ?? 0,
8
+ joinKey: raw.JoinKey ?? null,
9
+ accVerifiedReq: raw.AccVerifiedReq ?? null,
10
+ teamBalance: raw.TeamBalance ?? null,
11
+ players: Array.isArray(raw.Players) ? raw.Players : [],
12
+ staff: raw.Staff ?? { Admins: {}, Mods: {}, Helpers: {} },
13
+ joinLogs: Array.isArray(raw.JoinLogs) ? raw.JoinLogs : [],
14
+ queue: Array.isArray(raw.Queue) ? raw.Queue : [],
15
+ killLogs: Array.isArray(raw.KillLogs) ? raw.KillLogs : [],
16
+ commandLogs: Array.isArray(raw.CommandLogs) ? raw.CommandLogs : [],
17
+ modCalls: Array.isArray(raw.ModCalls) ? raw.ModCalls : [],
18
+ vehicles: Array.isArray(raw.Vehicles) ? raw.Vehicles : [],
19
+ raw,
20
+ };
21
+ }
22
+
23
+ module.exports = {
24
+ normalizeServerResponse,
25
+ };
@@ -0,0 +1,25 @@
1
+ function isPlainObject(value) {
2
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+
5
+ function mergeOptions(base, overrides) {
6
+ if (!isPlainObject(overrides)) {
7
+ return { ...base };
8
+ }
9
+
10
+ const output = { ...base };
11
+ const keys = Object.keys(overrides);
12
+ for (const key of keys) {
13
+ const nextValue = overrides[key];
14
+ if (isPlainObject(nextValue) && isPlainObject(output[key])) {
15
+ output[key] = mergeOptions(output[key], nextValue);
16
+ } else if (nextValue !== undefined) {
17
+ output[key] = nextValue;
18
+ }
19
+ }
20
+ return output;
21
+ }
22
+
23
+ module.exports = {
24
+ mergeOptions,
25
+ };
@@ -0,0 +1,22 @@
1
+ function sortValue(value) {
2
+ if (Array.isArray(value)) {
3
+ return value.map(sortValue);
4
+ }
5
+ if (value && typeof value === "object") {
6
+ const sorted = {};
7
+ const keys = Object.keys(value).sort();
8
+ for (const key of keys) {
9
+ sorted[key] = sortValue(value[key]);
10
+ }
11
+ return sorted;
12
+ }
13
+ return value;
14
+ }
15
+
16
+ function stableStringify(value) {
17
+ return JSON.stringify(sortValue(value));
18
+ }
19
+
20
+ module.exports = {
21
+ stableStringify,
22
+ };