erlc-v2 1.0.0 → 1.1.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,538 @@
1
+ const http = require("http");
2
+ const { QUERY_FLAG_MAP } = require("../util/constants");
3
+ const { searchVehicles } = require("../util/vehicleSearch");
4
+ const {
5
+ getHeader,
6
+ verifyWebhookSignature,
7
+ normalizeWebhookPayload,
8
+ } = require("../util/webhook");
9
+
10
+ function cleanPath(value, fallback) {
11
+ const input = String(value || fallback || "/").trim();
12
+ const withLeadingSlash = input.startsWith("/") ? input : `/${input}`;
13
+ if (withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/")) {
14
+ return withLeadingSlash.slice(0, -1);
15
+ }
16
+ return withLeadingSlash;
17
+ }
18
+
19
+ function buildUrl(base, path) {
20
+ if (!base) return null;
21
+ return new URL(path, base.endsWith("/") ? base : `${base}/`).toString();
22
+ }
23
+
24
+ function sendJson(res, status, payload) {
25
+ const body = Buffer.from(JSON.stringify(payload));
26
+ res.statusCode = status;
27
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
28
+ res.setHeader("Content-Length", body.length);
29
+ res.end(body);
30
+ }
31
+
32
+ function sendEmpty(res, status) {
33
+ res.statusCode = status;
34
+ res.end();
35
+ }
36
+
37
+ function readBody(req) {
38
+ return new Promise((resolve, reject) => {
39
+ const chunks = [];
40
+ req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
41
+ req.on("end", () => resolve(Buffer.concat(chunks)));
42
+ req.on("error", reject);
43
+ });
44
+ }
45
+
46
+ function parseJson(rawBody) {
47
+ if (!rawBody || rawBody.length === 0) return {};
48
+ return JSON.parse(rawBody.toString("utf8"));
49
+ }
50
+
51
+ function parseBool(value) {
52
+ if (typeof value !== "string") return false;
53
+ const text = value.trim().toLowerCase();
54
+ return text === "true" || text === "1" || text === "yes";
55
+ }
56
+
57
+ function bodyPreview(body) {
58
+ if (!body || typeof body !== "object") return body ?? null;
59
+ try {
60
+ return JSON.parse(JSON.stringify(body));
61
+ } catch {
62
+ return { message: "unserializable body" };
63
+ }
64
+ }
65
+
66
+ function getRoutePath(url, basePath) {
67
+ if (url.pathname === basePath) return "";
68
+ if (!url.pathname.startsWith(`${basePath}/`)) return null;
69
+ return url.pathname.slice(basePath.length);
70
+ }
71
+
72
+ class LocalApiServer {
73
+ constructor(client, options = {}) {
74
+ this.client = client;
75
+ this.options = options || {};
76
+ this.server = null;
77
+ this.startPromise = null;
78
+ }
79
+
80
+ info() {
81
+ const basePath = cleanPath(this.options.path, "/erlc");
82
+ const webhookPath = cleanPath(
83
+ this.options.webhookPath || `${basePath}/events`,
84
+ `${basePath}/events`,
85
+ );
86
+ const address = this.server?.address();
87
+ const host =
88
+ typeof this.options.host === "string" && this.options.host.trim()
89
+ ? this.options.host.trim()
90
+ : "127.0.0.1";
91
+ const port =
92
+ address && typeof address === "object" ? address.port : this.options.port;
93
+ const localHost =
94
+ host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
95
+ const localUrl =
96
+ Number.isInteger(port) && port >= 0
97
+ ? `http://${localHost}:${port}${basePath}`
98
+ : null;
99
+
100
+ return {
101
+ running: Boolean(this.server?.listening),
102
+ host,
103
+ port: Number.isInteger(port) ? port : null,
104
+ path: basePath,
105
+ webhookPath,
106
+ localUrl,
107
+ webhookUrl: buildUrl(this.options.publicUrl, webhookPath),
108
+ publicUrl: this.options.publicUrl || null,
109
+ tokenEnabled: Boolean(this.options.token),
110
+ };
111
+ }
112
+
113
+ async start() {
114
+ if (this.server?.listening) {
115
+ return this.info();
116
+ }
117
+ if (this.startPromise) {
118
+ return this.startPromise;
119
+ }
120
+
121
+ const port = Number(this.options.port);
122
+ if (!Number.isInteger(port) || port < 0) {
123
+ throw new Error("Local API requires a valid api.port");
124
+ }
125
+
126
+ const host =
127
+ typeof this.options.host === "string" && this.options.host.trim()
128
+ ? this.options.host.trim()
129
+ : "127.0.0.1";
130
+
131
+ this.server = http.createServer((req, res) => {
132
+ void this._handle(req, res);
133
+ });
134
+
135
+ this.startPromise = new Promise((resolve, reject) => {
136
+ const onError = (error) => {
137
+ this.server?.off("listening", onListening);
138
+ this.startPromise = null;
139
+ reject(error);
140
+ };
141
+ const onListening = () => {
142
+ this.server?.off("error", onError);
143
+ this.startPromise = null;
144
+ this.client.logger.info({
145
+ msg: "local_api_started",
146
+ ...this.info(),
147
+ });
148
+ resolve(this.info());
149
+ };
150
+
151
+ this.server.once("error", onError);
152
+ this.server.once("listening", onListening);
153
+ this.server.listen(port, host);
154
+ });
155
+
156
+ return this.startPromise;
157
+ }
158
+
159
+ stop() {
160
+ if (!this.server) return;
161
+ const server = this.server;
162
+ this.server = null;
163
+ this.startPromise = null;
164
+ server.close();
165
+ }
166
+
167
+ async _handle(req, res) {
168
+ const info = this.info();
169
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
170
+ const startedAt = Date.now();
171
+
172
+ try {
173
+ if (url.pathname === info.webhookPath && req.method === "POST") {
174
+ await this._handleWebhook(req, res);
175
+ return;
176
+ }
177
+
178
+ const routePath = getRoutePath(url, info.path);
179
+ if (routePath === null) {
180
+ this._recordRequest({
181
+ req,
182
+ url,
183
+ startedAt,
184
+ kind: "route",
185
+ status: 404,
186
+ note: "not_found",
187
+ });
188
+ sendJson(res, 404, { message: "Not found" });
189
+ return;
190
+ }
191
+
192
+ if (!this._isAuthorized(req)) {
193
+ this._recordRequest({
194
+ req,
195
+ url,
196
+ startedAt,
197
+ kind: "route",
198
+ status: 401,
199
+ note: "unauthorized",
200
+ });
201
+ sendJson(res, 401, { message: "Unauthorized" });
202
+ return;
203
+ }
204
+
205
+ if (req.method === "GET" && routePath === "") {
206
+ this._recordRequest({
207
+ req,
208
+ url,
209
+ startedAt,
210
+ kind: "route",
211
+ status: 200,
212
+ });
213
+ sendJson(res, 200, this.info());
214
+ return;
215
+ }
216
+
217
+ if (req.method === "GET" && routePath === "/health") {
218
+ this._recordRequest({
219
+ req,
220
+ url,
221
+ startedAt,
222
+ kind: "route",
223
+ status: 200,
224
+ });
225
+ sendJson(res, 200, {
226
+ ok: true,
227
+ polling: this.client.poller?.running === true,
228
+ disconnected: this.client.state?.disconnected === true,
229
+ });
230
+ return;
231
+ }
232
+
233
+ if (req.method === "GET" && routePath === "/server") {
234
+ const snapshot = await this.client.server.fetch(
235
+ this._getServerFlags(url.searchParams),
236
+ {
237
+ bypassCache: parseBool(url.searchParams.get("bypassCache")),
238
+ },
239
+ );
240
+ this._recordRequest({
241
+ req,
242
+ url,
243
+ startedAt,
244
+ kind: "route",
245
+ status: 200,
246
+ note: "server_snapshot",
247
+ });
248
+ sendJson(res, 200, snapshot);
249
+ return;
250
+ }
251
+
252
+ if (req.method === "GET" && routePath === "/players") {
253
+ const players = await this.client.players.list({
254
+ bypassCache: parseBool(url.searchParams.get("bypassCache")),
255
+ });
256
+ this._recordRequest({
257
+ req,
258
+ url,
259
+ startedAt,
260
+ kind: "route",
261
+ status: 200,
262
+ note: `players=${players.length}`,
263
+ });
264
+ sendJson(res, 200, {
265
+ count: players.length,
266
+ players,
267
+ });
268
+ return;
269
+ }
270
+
271
+ if (req.method === "GET" && routePath === "/vehicles") {
272
+ const vehicles = await this.client.vehicles.list({
273
+ bypassCache: parseBool(url.searchParams.get("bypassCache")),
274
+ });
275
+ const filtered = searchVehicles(vehicles, {
276
+ query: url.searchParams.get("query") || url.searchParams.get("search"),
277
+ plate: url.searchParams.get("plate"),
278
+ owner: url.searchParams.get("owner"),
279
+ name: url.searchParams.get("name"),
280
+ color: url.searchParams.get("color"),
281
+ texture: url.searchParams.get("texture"),
282
+ exact: parseBool(url.searchParams.get("exact")),
283
+ limit: url.searchParams.get("limit"),
284
+ });
285
+ this._recordRequest({
286
+ req,
287
+ url,
288
+ startedAt,
289
+ kind: "route",
290
+ status: 200,
291
+ note: `vehicles=${filtered.length}`,
292
+ });
293
+ sendJson(res, 200, {
294
+ count: filtered.length,
295
+ vehicles: filtered,
296
+ });
297
+ return;
298
+ }
299
+
300
+ if (req.method === "GET" && routePath.startsWith("/vehicles/")) {
301
+ const plate = decodeURIComponent(routePath.slice("/vehicles/".length));
302
+ const vehicle = await this.client.vehicles.findByPlate(plate, {
303
+ bypassCache: parseBool(url.searchParams.get("bypassCache")),
304
+ });
305
+ if (!vehicle) {
306
+ this._recordRequest({
307
+ req,
308
+ url,
309
+ startedAt,
310
+ kind: "route",
311
+ status: 404,
312
+ note: `plate=${plate}`,
313
+ });
314
+ sendJson(res, 404, { message: "Vehicle not found" });
315
+ return;
316
+ }
317
+ this._recordRequest({
318
+ req,
319
+ url,
320
+ startedAt,
321
+ kind: "route",
322
+ status: 200,
323
+ note: `plate=${plate}`,
324
+ });
325
+ sendJson(res, 200, vehicle);
326
+ return;
327
+ }
328
+
329
+ if (req.method === "GET" && routePath === "/emergency-calls") {
330
+ const calls = await this.client.logs.emergencyCalls({
331
+ bypassCache: parseBool(url.searchParams.get("bypassCache")),
332
+ });
333
+ this._recordRequest({
334
+ req,
335
+ url,
336
+ startedAt,
337
+ kind: "route",
338
+ status: 200,
339
+ note: `emergencyCalls=${calls.length}`,
340
+ });
341
+ sendJson(res, 200, {
342
+ count: calls.length,
343
+ emergencyCalls: calls,
344
+ });
345
+ return;
346
+ }
347
+
348
+ if (req.method === "POST" && routePath === "/command") {
349
+ const rawBody = await readBody(req);
350
+ const body = parseJson(rawBody);
351
+ const result = await this.client.commands.execute(body.command);
352
+ this._recordRequest({
353
+ req,
354
+ url,
355
+ startedAt,
356
+ kind: "route",
357
+ status: 200,
358
+ body,
359
+ note: "command",
360
+ });
361
+ sendJson(res, 200, result);
362
+ return;
363
+ }
364
+
365
+ this._recordRequest({
366
+ req,
367
+ url,
368
+ startedAt,
369
+ kind: "route",
370
+ status: 404,
371
+ note: "not_found",
372
+ });
373
+ sendJson(res, 404, { message: "Not found" });
374
+ } catch (error) {
375
+ const status = Number(error?.status) || 500;
376
+ this._recordRequest({
377
+ req,
378
+ url,
379
+ startedAt,
380
+ kind: "route",
381
+ status,
382
+ note: error?.message || "internal_error",
383
+ });
384
+ sendJson(res, status, {
385
+ message: error?.message || "Internal server error",
386
+ status,
387
+ code: error?.errorCode ?? null,
388
+ });
389
+ }
390
+ }
391
+
392
+ async _handleWebhook(req, res) {
393
+ const timestamp = getHeader(req, "x-signature-timestamp");
394
+ const signature = getHeader(req, "x-signature-ed25519");
395
+ const rawBody = await readBody(req);
396
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
397
+ const startedAt = Date.now();
398
+
399
+ if (!timestamp || !signature) {
400
+ this._recordRequest({
401
+ req,
402
+ url,
403
+ startedAt,
404
+ kind: "webhook",
405
+ status: 400,
406
+ note: "missing_signature_headers",
407
+ });
408
+ sendJson(res, 400, { message: "Missing signature headers" });
409
+ return;
410
+ }
411
+
412
+ if (!verifyWebhookSignature(timestamp, signature, rawBody)) {
413
+ this._recordRequest({
414
+ req,
415
+ url,
416
+ startedAt,
417
+ kind: "webhook",
418
+ status: 401,
419
+ note: "invalid_signature",
420
+ });
421
+ sendJson(res, 401, { message: "Invalid signature" });
422
+ return;
423
+ }
424
+
425
+ let body;
426
+ try {
427
+ body = parseJson(rawBody);
428
+ } catch {
429
+ this._recordRequest({
430
+ req,
431
+ url,
432
+ startedAt,
433
+ kind: "webhook",
434
+ status: 400,
435
+ note: "invalid_json",
436
+ });
437
+ sendJson(res, 400, { message: "Invalid JSON body" });
438
+ return;
439
+ }
440
+
441
+ const payload = normalizeWebhookPayload(body, {
442
+ timestamp,
443
+ signature,
444
+ rawBody,
445
+ headers: req.headers,
446
+ });
447
+
448
+ this._recordRequest({
449
+ req,
450
+ url,
451
+ startedAt,
452
+ kind: "webhook",
453
+ status: 204,
454
+ type: payload.type,
455
+ body,
456
+ note: "signature_valid",
457
+ });
458
+
459
+ this.client.emit("webhook", payload);
460
+ if (payload.type === "command") {
461
+ this.client.emit("webhookCommand", payload);
462
+ }
463
+ if (payload.type === "emergencyCall") {
464
+ this.client.emit("webhookEmergencyCall", payload);
465
+ }
466
+
467
+ sendEmpty(res, 204);
468
+ }
469
+
470
+ _recordRequest({
471
+ req,
472
+ url,
473
+ startedAt,
474
+ kind,
475
+ status,
476
+ type = null,
477
+ body = null,
478
+ note = "",
479
+ }) {
480
+ const payload = {
481
+ method: req.method || "GET",
482
+ path: url.pathname,
483
+ query: Object.fromEntries(url.searchParams.entries()),
484
+ kind,
485
+ status,
486
+ type,
487
+ body: bodyPreview(body),
488
+ note,
489
+ tookMs: Date.now() - startedAt,
490
+ ip:
491
+ req.socket?.remoteAddress ||
492
+ req.headers["x-forwarded-for"] ||
493
+ null,
494
+ at: new Date().toISOString(),
495
+ };
496
+
497
+ this.client.emit("apiRequest", payload);
498
+
499
+ if (this.options.logRequests === false) return;
500
+
501
+ const parts = [
502
+ `[ERLC API] ${payload.method} ${payload.path}`,
503
+ `status=${payload.status}`,
504
+ `kind=${payload.kind}`,
505
+ ];
506
+ if (payload.type) parts.push(`type=${payload.type}`);
507
+ if (payload.note) parts.push(`note=${payload.note}`);
508
+ parts.push(`took=${payload.tookMs}ms`);
509
+
510
+ console.log(parts.join(" | "));
511
+ if (payload.body) {
512
+ console.log(JSON.stringify(payload.body, null, 2));
513
+ }
514
+ }
515
+
516
+ _isAuthorized(req) {
517
+ const token = String(this.options.token || "").trim();
518
+ if (!token) return true;
519
+
520
+ const auth = getHeader(req, "authorization");
521
+ if (auth === `Bearer ${token}`) return true;
522
+
523
+ const alt = getHeader(req, "x-api-token");
524
+ return alt === token;
525
+ }
526
+
527
+ _getServerFlags(searchParams) {
528
+ const flags = {};
529
+ for (const [key, apiKey] of Object.entries(QUERY_FLAG_MAP)) {
530
+ if (parseBool(searchParams.get(key)) || parseBool(searchParams.get(apiKey))) {
531
+ flags[key] = true;
532
+ }
533
+ }
534
+ return flags;
535
+ }
536
+ }
537
+
538
+ module.exports = LocalApiServer;
@@ -58,6 +58,7 @@ class Poller {
58
58
  joins: new SeenSignatures(),
59
59
  commands: new SeenSignatures(),
60
60
  modCalls: new SeenSignatures(),
61
+ emergencyCalls: new SeenSignatures(),
61
62
  };
62
63
  }
63
64
 
@@ -98,6 +99,8 @@ class Poller {
98
99
  this.seen.commands.seed(buildLogSignature("commands", item));
99
100
  for (const item of snapshot.modCalls)
100
101
  this.seen.modCalls.seed(buildLogSignature("modCalls", item));
102
+ for (const item of snapshot.emergencyCalls)
103
+ this.seen.emergencyCalls.seed(buildLogSignature("emergencyCalls", item));
101
104
  }
102
105
 
103
106
  _emitServerUpdate(current) {
@@ -241,6 +244,16 @@ class Poller {
241
244
  });
242
245
  }
243
246
  }
247
+
248
+ for (const emergencyCall of current.emergencyCalls) {
249
+ const signature = buildLogSignature("emergencyCalls", emergencyCall);
250
+ if (!this.seen.emergencyCalls.checkAndAdd(signature)) continue;
251
+ this.client.emit("emergencyCall", {
252
+ emergencyCall,
253
+ signature,
254
+ snapshot: current,
255
+ });
256
+ }
244
257
  }
245
258
 
246
259
  _handleSnapshot(current) {
@@ -280,6 +293,7 @@ class Poller {
280
293
  killLogs: true,
281
294
  commandLogs: true,
282
295
  modCalls: true,
296
+ emergencyCalls: true,
283
297
  vehicles: true,
284
298
  },
285
299
  {
@@ -10,10 +10,11 @@ function getVehicleKey(vehicle) {
10
10
  if (!vehicle) return "";
11
11
  const name = vehicle.Name ?? "";
12
12
  const owner = vehicle.Owner ?? "";
13
+ const plate = vehicle.Plate ?? "";
13
14
  const texture = vehicle.Texture ?? "";
14
15
  const colorHex = vehicle.ColorHex ?? "";
15
16
  const colorName = vehicle.ColorName ?? "";
16
- return `${name}|${owner}|${texture}|${colorHex}|${colorName}`;
17
+ return `${name}|${owner}|${plate}|${texture}|${colorHex}|${colorName}`;
17
18
  }
18
19
 
19
20
  function diffByKey(previous, current, keyFn) {
@@ -74,6 +75,14 @@ function buildLogSignature(type, item) {
74
75
  caller: item?.Caller ?? "",
75
76
  moderator: item?.Moderator ?? "",
76
77
  });
78
+ case "emergencyCalls":
79
+ return stableStringify({
80
+ startedAt: item?.StartedAt ?? null,
81
+ callNumber: item?.CallNumber ?? null,
82
+ team: item?.Team ?? "",
83
+ caller: item?.Caller ?? "",
84
+ description: item?.Description ?? "",
85
+ });
77
86
  default:
78
87
  return stableStringify(item);
79
88
  }
@@ -21,6 +21,7 @@ const QUERY_FLAG_MAP = {
21
21
  killLogs: "KillLogs",
22
22
  commandLogs: "CommandLogs",
23
23
  modCalls: "ModCalls",
24
+ emergencyCalls: "EmergencyCalls",
24
25
  vehicles: "Vehicles",
25
26
  };
26
27
 
@@ -43,6 +44,16 @@ const DEFAULT_OPTIONS = {
43
44
  intervalMs: 2500,
44
45
  bypassCache: true,
45
46
  },
47
+ api: {
48
+ enabled: false,
49
+ host: "127.0.0.1",
50
+ port: null,
51
+ path: "/erlc",
52
+ webhookPath: null,
53
+ publicUrl: "",
54
+ token: "",
55
+ logRequests: true,
56
+ },
46
57
  };
47
58
 
48
59
  module.exports = {
@@ -65,7 +65,7 @@ function createApiError({
65
65
  return new ERLCAPIError(message, common);
66
66
  }
67
67
 
68
- return new ERLCHttpError(`HTTP ${status}`, {
68
+ return new ERLCHttpError(message === API_ERROR_MESSAGES[0] ? `HTTP ${status}` : message, {
69
69
  status,
70
70
  endpoint,
71
71
  bucket,
@@ -15,6 +15,7 @@ function normalizeServerResponse(raw = {}) {
15
15
  killLogs: Array.isArray(raw.KillLogs) ? raw.KillLogs : [],
16
16
  commandLogs: Array.isArray(raw.CommandLogs) ? raw.CommandLogs : [],
17
17
  modCalls: Array.isArray(raw.ModCalls) ? raw.ModCalls : [],
18
+ emergencyCalls: Array.isArray(raw.EmergencyCalls) ? raw.EmergencyCalls : [],
18
19
  vehicles: Array.isArray(raw.Vehicles) ? raw.Vehicles : [],
19
20
  raw,
20
21
  };