neekuro 2.0.4 → 2.2.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.
@@ -2,7 +2,15 @@
2
2
  const Canvas = require('canvas');
3
3
  const NekoError = require('../errors/Error.js');
4
4
  const hex_reg = require('hex-color-regex')({ strict: true })
5
- const path = require('path');
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+ const { loadImageSource, parseRemoteURL, validateImageBuffer } = require('../../utils/imageLoader.js');
8
+
9
+ const DEFAULT_WIDTH = 1140;
10
+ const DEFAULT_HEIGHT = 520;
11
+ const MIN_DIMENSION = 128;
12
+ const MAX_DIMENSION = 4096;
13
+ const registeredFonts = new Set();
6
14
 
7
15
  /**
8
16
  * @typedef {import('../../typings').FontNames} FontNames
@@ -29,7 +37,8 @@ class Welcome {
29
37
  constructor() {
30
38
  /**
31
39
  * @property {Object} data - Configuración actual de la imagen
32
- * @property {string} data.font - Fuente actualmente seleccionada
40
+ * @property {string} data.font - Fuente actualmente seleccionada
41
+ * @property {'center'|'manual'} data.layout - Distribución de los elementos
33
42
  * @property {number} data.width - Ancho actual de la imagen
34
43
  * @property {number} data.height - Alto actual de la imagen
35
44
  * @property {Object} data.background - Configuración del fondo
@@ -54,10 +63,11 @@ class Welcome {
54
63
  * @property {number} data.description.font_size - Tamaño de fuente de la descripción
55
64
  * @property {string} data.description.text_color - Color del texto de la descripción
56
65
  */
57
- this.data = {
58
- font: 'arial',
59
- width: 1140,
60
- height: 520,
66
+ this.data = {
67
+ font: 'arial',
68
+ layout: 'center',
69
+ width: DEFAULT_WIDTH,
70
+ height: DEFAULT_HEIGHT,
61
71
  background: {
62
72
  type: 'color',
63
73
  value: '#23272A'
@@ -83,8 +93,9 @@ class Welcome {
83
93
  font_size: 35,
84
94
  text_color: "#F7F7F7"
85
95
  }
86
- }
87
- }
96
+ }
97
+ this.setFont('arial');
98
+ }
88
99
 
89
100
  /**
90
101
  * Establece la resolución de la imagen de bienvenida
@@ -99,22 +110,28 @@ class Welcome {
99
110
  * // Restablecer a valores por defecto
100
111
  * welcome.setResolution('default', 'default');
101
112
  */
102
- setResolution(width, height) {
113
+ setResolution(width, height) {
103
114
  if (!width) throw new NekoError('ResolutionError', 'el parametro <width> no puede quedar vacio');
104
115
  if (!height) throw new NekoError('ResolutionError', 'el parametro <height> no puede quedar vacio');
105
116
 
106
- if (width == 'default') width = this.data.width; else {
107
- if (typeof width != 'number') throw new NekoError('TypeError', 'el parametro <width> solo acepta tipo \'number\', recibió ' + typeof width);
108
- this.data.width = width;
109
- }
110
-
111
- if (height == 'default') height = this.data.height; else {
112
- if (typeof height != 'number') throw new NekoError('TypeError', 'el parametro <height> solo acepta tipo \'number\', recibió ' + typeof height);
113
- this.data.height = height;
114
- }
115
-
116
- return this
117
- }
117
+ this.data.width = width === 'default' ? DEFAULT_WIDTH : validateDimension(width, 'width');
118
+ this.data.height = height === 'default' ? DEFAULT_HEIGHT : validateDimension(height, 'height');
119
+
120
+ return this
121
+ }
122
+
123
+ /**
124
+ * Selecciona la distribución automática centrada o las coordenadas manuales.
125
+ * @param {'center'|'manual'} layout
126
+ * @returns {Welcome}
127
+ */
128
+ setLayout(layout) {
129
+ if (!['center', 'manual'].includes(layout)) {
130
+ throw new NekoError('LayoutError', 'El layout debe ser "center" o "manual"');
131
+ }
132
+ this.data.layout = layout;
133
+ return this;
134
+ }
118
135
 
119
136
  /**
120
137
  * Configura la tipografía para el texto de la imagen
@@ -145,13 +162,18 @@ class Welcome {
145
162
  case 'custom':
146
163
  if (!dir) throw new NekoError('FontError', 'el parametro <dir> no puede quedar vacio en un tipo de fuente customizada');
147
164
  if (typeof dir != 'string') throw new NekoError('TypeError', 'el parametro <dir> solo acepta tipo \'string\', recibió ' + typeof dir);
148
- if (!dir.endsWith('ttf')) throw new NekoError('FontError', 'se nesecita un archivo ttf en el parametro <dir>')
149
- ruta = path.join(__dirname, '../../../../' + dir.replace('./', ''));
165
+ if (path.extname(dir).toLowerCase() !== '.ttf') throw new NekoError('FontError', 'se necesita un archivo TTF en el parametro <dir>')
166
+ ruta = path.resolve(dir);
150
167
  break;
151
168
  default: throw new NekoError('FontError', 'el parametro <name> no acepta ningun tipo diferente a los preterminados');
152
169
  }
153
170
  try {
154
- Canvas.registerFont(ruta, { family: name })
171
+ if (!fs.existsSync(ruta)) throw new Error('Font not found');
172
+ const fontKey = `${name}:${ruta}`;
173
+ if (!registeredFonts.has(fontKey)) {
174
+ Canvas.registerFont(ruta, { family: name });
175
+ registeredFonts.add(fontKey);
176
+ }
155
177
  } catch (error) {
156
178
  throw new NekoError('DirError', 'no se puede encontrar el directorio especificado')
157
179
  }
@@ -206,18 +228,20 @@ class Welcome {
206
228
  }
207
229
 
208
230
  // validacion de posiciones
209
- if (data.x) {
231
+ if (data.x !== undefined) {
210
232
  if (typeof data.x !== 'number') {
211
233
  throw new NekoError('TypeError', `La propiedad <x> solo acepta tipo 'number', recibió ${typeof data.x}`);
212
234
  }
213
- this.data.title.x = data.x;
235
+ this.data.title.x = data.x;
236
+ this.data.layout = 'manual';
214
237
  }
215
238
 
216
- if (data.y) {
239
+ if (data.y !== undefined) {
217
240
  if (typeof data.y !== 'number') {
218
241
  throw new NekoError('TypeError', `La propiedad <y> solo acepta tipo 'number', recibió ${typeof data.y}`);
219
242
  }
220
- this.data.title.y = data.y;
243
+ this.data.title.y = data.y;
244
+ this.data.layout = 'manual';
221
245
  }
222
246
  }
223
247
 
@@ -270,18 +294,20 @@ class Welcome {
270
294
  }
271
295
 
272
296
  // validacion de posiciones
273
- if (data.x) {
297
+ if (data.x !== undefined) {
274
298
  if (typeof data.x !== 'number') {
275
299
  throw new NekoError('TypeError', `La propiedad <x> solo acepta tipo 'number', recibió ${typeof data.x}`);
276
300
  }
277
- this.data.description.x = data.x;
301
+ this.data.description.x = data.x;
302
+ this.data.layout = 'manual';
278
303
  }
279
304
 
280
- if (data.y) {
305
+ if (data.y !== undefined) {
281
306
  if (typeof data.y !== 'number') {
282
307
  throw new NekoError('TypeError', `La propiedad <y> solo acepta tipo 'number', recibió ${typeof data.y}`);
283
308
  }
284
- this.data.description.y = data.y;
309
+ this.data.description.y = data.y;
310
+ this.data.layout = 'manual';
285
311
  }
286
312
  }
287
313
 
@@ -327,11 +353,9 @@ class Welcome {
327
353
  }
328
354
 
329
355
  if (typeof value == 'string') {
330
- try {
331
- new URL(value);
332
- } catch (error) {
333
- throw new NekoError('BackgroundError', 'La URL proporcionada no es válida');
334
- }
356
+ parseRemoteURL(value, 'el fondo');
357
+ } else {
358
+ validateImageBuffer(value, 'el fondo');
335
359
  }
336
360
 
337
361
  this.data.background = { type, value };
@@ -369,56 +393,33 @@ class Welcome {
369
393
  }
370
394
 
371
395
  if (typeof source === 'string') {
372
- try {
373
- const url = new URL(source);
374
-
375
- if (!['http:', 'https:'].includes(url.protocol)) {
376
- throw new NekoError('AvatarError', 'La URL debe usar HTTP/HTTPS');
377
- }
378
-
379
- const extension = url.pathname.toLowerCase().split('.').pop();
380
- if (!['jpg', 'jpeg', 'png', 'gif'].includes(extension)) {
381
- throw new NekoError('AvatarError', 'Formato de imagen no soportado. Usa JPEG, PNG o GIF');
382
- }
383
- } catch (error) {
384
- if (error instanceof NekoError) throw error;
385
- throw new NekoError('AvatarError', 'La URL del avatar no es válida');
386
- }
396
+ parseRemoteURL(source, 'el avatar');
387
397
  }
388
398
 
389
399
  if (Buffer.isBuffer(source)) {
390
- if (source.length < 8) {
391
- throw new NekoError('AvatarError', 'El Buffer de imagen es demasiado pequeño');
392
- }
393
-
394
- const header = source.toString('hex', 0, 8);
395
- const isJPEG = header.startsWith('ffd8ff');
396
- const isPNG = header.startsWith('89504e470d0a1a0a');
397
- const isGIF = header.startsWith('47494638');
398
-
399
- if (!isJPEG && !isPNG && !isGIF) {
400
- throw new NekoError('AvatarError', 'Formato de imagen no soportado. El Buffer debe ser JPEG, PNG o GIF');
401
- }
400
+ validateImageBuffer(source, 'el avatar');
402
401
  }
403
402
 
404
403
  this.data.avatar.source = source;
405
404
 
406
405
  if (data) {
407
- if (data.x) {
406
+ if (data.x !== undefined) {
408
407
  if (typeof data.x != 'number') {
409
408
  throw new NekoError('TypeError', 'La propiedad <x> debe ser un número');
410
409
  }
411
410
  if (data.x < 0) {
412
411
  throw new NekoError('AvatarError', 'La posición X no puede ser negativa');
413
412
  }
414
- this.data.avatar.x = data.x;
413
+ this.data.avatar.x = data.x;
414
+ this.data.layout = 'manual';
415
415
  }
416
416
 
417
- if (data.y) {
417
+ if (data.y !== undefined) {
418
418
  if (typeof data.y != 'number') {
419
419
  throw new NekoError('TypeError', 'La propiedad <y> debe ser un número');
420
420
  }
421
- this.data.avatar.y = data.y;
421
+ this.data.avatar.y = data.y;
422
+ this.data.layout = 'manual';
422
423
  }
423
424
 
424
425
  if (data.border) {
@@ -434,10 +435,13 @@ class Welcome {
434
435
  this.data.avatar.border = data.border;
435
436
  }
436
437
 
437
- if (data.radio) {
438
- if (typeof data.radio != 'number') {
439
- throw new NekoError('TypeError', 'La propiedad <radio> debe ser un número');
440
- }
438
+ if (data.radio !== undefined) {
439
+ if (typeof data.radio != 'number') {
440
+ throw new NekoError('TypeError', 'La propiedad <radio> debe ser un número');
441
+ }
442
+ if (!Number.isFinite(data.radio) || data.radio <= 0 || data.radio > Math.min(this.data.width, this.data.height) / 2) {
443
+ throw new NekoError('AvatarError', 'El radio debe ser positivo y caber dentro de la imagen');
444
+ }
441
445
  this.data.avatar.radio = data.radio;
442
446
  }
443
447
  }
@@ -464,8 +468,8 @@ class Welcome {
464
468
 
465
469
  try {
466
470
  const [bg, avatar] = await Promise.all([
467
- this.data.background.type == 'image' ? Canvas.loadImage(this.data.background.value) : null,
468
- Canvas.loadImage(this.data.avatar.source)
471
+ this.data.background.type == 'image' ? loadImageSource(this.data.background.value, 'el fondo') : null,
472
+ loadImageSource(this.data.avatar.source, 'el avatar')
469
473
  ])
470
474
 
471
475
  const canvas = Canvas.createCanvas(this.data.width, this.data.height)
@@ -481,35 +485,43 @@ class Welcome {
481
485
  ctx.fillRect(0, 0, canvas.width, canvas.height);
482
486
  }
483
487
 
484
- const maxTitleWidth = this.data.width * 0.8;
485
- let titleFontSize = this.data.title.font_size;
486
- ctx.font = `${titleFontSize}px ${this.data.font}`;
488
+ const maxTitleWidth = this.data.width * 0.8;
489
+ let titleFontSize = this.data.layout === 'center'
490
+ ? Math.min(this.data.title.font_size, this.data.height * 0.14)
491
+ : this.data.title.font_size;
492
+ ctx.font = `${titleFontSize}px ${this.data.font}`;
487
493
 
488
494
  while (ctx.measureText(this.data.title.content).width > maxTitleWidth && titleFontSize > 10) {
489
495
  titleFontSize -= 1;
490
496
  ctx.font = `${titleFontSize}px ${this.data.font}`;
491
497
  }
492
498
 
493
- ctx.fillStyle = this.data.title.text_color;
494
- ctx.textAlign = "center";
495
- ctx.fillText(this.data.title.content, this.data.title.x, this.data.title.y);
496
-
497
- const maxDescWidth = this.data.width * 0.9;
498
- let descFontSize = this.data.description.font_size;
499
- ctx.font = `${descFontSize}px ${this.data.font}`;
499
+ const maxDescWidth = this.data.width * 0.9;
500
+ let descFontSize = this.data.layout === 'center'
501
+ ? Math.min(this.data.description.font_size, this.data.height * 0.09)
502
+ : this.data.description.font_size;
503
+ ctx.font = `${descFontSize}px ${this.data.font}`;
500
504
 
501
505
  while (ctx.measureText(this.data.description.content).width > maxDescWidth && descFontSize > 8) {
502
506
  descFontSize -= 1;
503
507
  ctx.font = `${descFontSize}px ${this.data.font}`;
504
508
  }
505
509
 
506
- ctx.fillStyle = this.data.description.text_color;
507
- ctx.textAlign = "center";
508
- ctx.fillText(this.data.description.content, this.data.description.x, this.data.description.y);
509
-
510
- const x = this.data.avatar.x;
511
- const y = this.data.avatar.y;
512
- const radio = this.data.avatar.radio;
510
+ const layout = this.data.layout === 'center'
511
+ ? calculateCenteredLayout(this.data, titleFontSize, descFontSize)
512
+ : {
513
+ avatarX: this.data.avatar.x,
514
+ avatarY: this.data.avatar.y,
515
+ radio: this.data.avatar.radio,
516
+ titleX: this.data.title.x,
517
+ titleY: this.data.title.y,
518
+ descriptionX: this.data.description.x,
519
+ descriptionY: this.data.description.y
520
+ };
521
+
522
+ const x = layout.avatarX;
523
+ const y = layout.avatarY;
524
+ const radio = layout.radio;
513
525
 
514
526
  if (this.data.avatar.border) {
515
527
  ctx.beginPath();
@@ -524,8 +536,18 @@ class Welcome {
524
536
  ctx.arc(x + radio, y + radio, radio, 0, Math.PI * 2, true);
525
537
  ctx.closePath();
526
538
  ctx.clip();
527
- ctx.drawImage(avatar, x, y, radio * 2, radio * 2);
528
- ctx.restore();
539
+ ctx.drawImage(avatar, x, y, radio * 2, radio * 2);
540
+ ctx.restore();
541
+
542
+ ctx.textAlign = 'center';
543
+ ctx.textBaseline = this.data.layout === 'center' ? 'middle' : 'alphabetic';
544
+ ctx.font = `${titleFontSize}px ${this.data.font}`;
545
+ ctx.fillStyle = this.data.title.text_color;
546
+ ctx.fillText(this.data.title.content, layout.titleX, layout.titleY);
547
+
548
+ ctx.font = `${descFontSize}px ${this.data.font}`;
549
+ ctx.fillStyle = this.data.description.text_color;
550
+ ctx.fillText(this.data.description.content, layout.descriptionX, layout.descriptionY);
529
551
 
530
552
  return canvas.toBuffer();
531
553
  } catch (error) {
@@ -535,6 +557,50 @@ class Welcome {
535
557
  throw new NekoError('GenerateError', 'Error al generar la imagen: '+error.message)
536
558
  }
537
559
  }
538
- }
539
-
540
- module.exports = Welcome;
560
+ }
561
+
562
+ function calculateCenteredLayout(data, titleFontSize, descFontSize) {
563
+ const borderSize = data.avatar.border ? 6 : 0;
564
+ const avatarGap = clamp(data.height * 0.045, 16, 32);
565
+ const textGap = clamp(data.height * 0.018, 8, 16);
566
+ const availableHeight = data.height * 0.9;
567
+ const maxRadio = Math.max(
568
+ 16,
569
+ (availableHeight - titleFontSize - descFontSize - avatarGap - textGap) / 2 - borderSize
570
+ );
571
+ const radio = Math.min(data.avatar.radio, maxRadio);
572
+ const avatarOuterSize = (radio + borderSize) * 2;
573
+ const contentHeight = avatarOuterSize + avatarGap + titleFontSize + textGap + descFontSize;
574
+ const startY = (data.height - contentHeight) / 2;
575
+ const centerX = data.width / 2;
576
+ const avatarX = centerX - radio;
577
+ const avatarY = startY + borderSize;
578
+ const titleY = startY + avatarOuterSize + avatarGap + titleFontSize / 2;
579
+ const descriptionY = titleY + titleFontSize / 2 + textGap + descFontSize / 2;
580
+
581
+ return {
582
+ avatarX,
583
+ avatarY,
584
+ radio,
585
+ titleX: centerX,
586
+ titleY,
587
+ descriptionX: centerX,
588
+ descriptionY
589
+ };
590
+ }
591
+
592
+ function clamp(value, min, max) {
593
+ return Math.min(Math.max(value, min), max);
594
+ }
595
+
596
+ function validateDimension(value, name) {
597
+ if (typeof value !== 'number' || !Number.isInteger(value) || !Number.isFinite(value)) {
598
+ throw new NekoError('TypeError', `el parametro <${name}> debe ser un número entero`);
599
+ }
600
+ if (value < MIN_DIMENSION || value > MAX_DIMENSION) {
601
+ throw new NekoError('ResolutionError', `<${name}> debe estar entre ${MIN_DIMENSION} y ${MAX_DIMENSION} píxeles`);
602
+ }
603
+ return value;
604
+ }
605
+
606
+ module.exports = Welcome;
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
- let tokenGlobal = null;
3
+ let tokenGlobal = null;
4
+ let tokenVersion = 0;
4
5
 
5
6
  /**
6
7
  * Clase BaseUser
@@ -11,17 +12,30 @@ class BaseUser {
11
12
  * Establece el token "global" en todo el paquete.
12
13
  * @param {string} token token a establecer.
13
14
  */
14
- static setToken(token) {
15
- tokenGlobal = token;
15
+ static setToken(token) {
16
+ if (typeof token !== 'string' || !token.trim()) {
17
+ throw new TypeError('El token debe ser una cadena no vacía');
18
+ }
19
+ const normalizedToken = token.trim();
20
+ if (tokenGlobal !== normalizedToken) tokenVersion += 1;
21
+ tokenGlobal = normalizedToken;
16
22
  }
17
23
 
18
24
  /**
19
25
  * Obten el token del usuario si este ya se estableció
20
26
  * @returns {string} Token de usuario
21
27
  */
22
- static getToken() {
23
- return tokenGlobal;
24
- }
25
- }
28
+ static getToken() {
29
+ return tokenGlobal;
30
+ }
31
+
32
+ /**
33
+ * Identificador interno que cambia cada vez que se establece otro token.
34
+ * @returns {number}
35
+ */
36
+ static getTokenVersion() {
37
+ return tokenVersion;
38
+ }
39
+ }
26
40
 
27
41
  module.exports = BaseUser;
@@ -12,9 +12,9 @@ class User {
12
12
  * Puedes establecer el token aqui, sin embargo, recomiendo hacerlo en el metodo dedicado
13
13
  * @param {string} token Token de la api
14
14
  */
15
- constructor(token) {
16
- this.token_ = token
17
- setToken(this.#getToken());
15
+ constructor(token) {
16
+ this.token_ = undefined;
17
+ if (token !== undefined) this.token(token);
18
18
  }
19
19
 
20
20
  /**
@@ -23,9 +23,9 @@ class User {
23
23
  * @throws {NekoError} si el token no fue dado o no es una cadena
24
24
  */
25
25
  token(token) {
26
- if(!token) throw new NekoError('NoToken', 'el parametro <token> es requerido');
27
- if(typeof(token) !== 'string') throw new NekoError('InvalidType', `Token invaldo... el tipo de dato es String no ${typeof(token)}`);
28
- this.token_ = token;
26
+ if(typeof(token) !== 'string') throw new NekoError('InvalidType', `El token debe ser string, no ${typeof(token)}`);
27
+ if(!token.trim()) throw new NekoError('NoToken', 'el parametro <token> es requerido');
28
+ this.token_ = token.trim();
29
29
  setToken(this.#getToken());
30
30
  }
31
31
 
@@ -38,4 +38,4 @@ class User {
38
38
  }
39
39
  }
40
40
 
41
- module.exports = User
41
+ module.exports = User
@@ -8,7 +8,7 @@ exports.User = require('../classes/user/User.js')
8
8
 
9
9
  exports.NekoGif = require('../classes/package/NekoGif.js')
10
10
  exports.BaseUser = require('../classes/user/BaseUser.js')
11
- exports.Welcome = require('../classes/package/Welcome.js')
11
+ exports.Welcome = require('../classes/package/welcome.js')
12
12
 
13
13
  // rest
14
14
 
@@ -16,9 +16,11 @@ exports.APIClient = require('../rest/APIClient.js')
16
16
 
17
17
  // errores
18
18
 
19
- exports.APIError = require('../classes/errors/APIerror.js')
20
- exports.Error = require('../classes/errors/Error.js')
19
+ exports.APIError = require('../classes/errors/APIError.js')
20
+ exports.NekoError = require('../classes/errors/Error.js')
21
+ // Alias conservado por compatibilidad con versiones 2.0.x.
22
+ exports.Error = exports.NekoError
21
23
 
22
24
  //#region "constantes"
23
25
 
24
- exports.SFW = require('../classes/SFW.js')
26
+ exports.SFW = require('../classes/SFW.js')
@@ -5,9 +5,10 @@
5
5
  * @typedef {import('../typings').GifResponse} GifResponse
6
6
  */
7
7
 
8
- const APIError = require('../classes/errors/APIerror.js');
9
-
10
- const fetch = import('node-fetch');
8
+ const APIError = require('../classes/errors/APIError.js');
9
+ const NekoError = require('../classes/errors/Error.js');
10
+
11
+ const DEFAULT_TIMEOUT_MS = 10_000;
11
12
 
12
13
  /**
13
14
  * Clase APIClient
@@ -19,12 +20,28 @@ class APIClient {
19
20
  * @param {string} baseURL link principal de la api
20
21
  * @param {APIClientData} data objeto para las solicitudes
21
22
  */
22
- constructor(baseURL, data) {
23
+ constructor(baseURL, data) {
24
+ if (typeof baseURL !== 'string' || !baseURL) {
25
+ throw new NekoError('InvalidBaseURL', 'El parámetro <baseURL> debe ser una URL válida');
26
+ }
27
+
28
+ let parsedBaseURL;
29
+ try {
30
+ parsedBaseURL = new URL(baseURL);
31
+ } catch {
32
+ throw new NekoError('InvalidBaseURL', 'El parámetro <baseURL> debe ser una URL válida');
33
+ }
34
+ if (parsedBaseURL.protocol !== 'https:') {
35
+ throw new NekoError('InvalidBaseURL', 'La API debe utilizar HTTPS');
36
+ }
37
+ if (!data || typeof data.token !== 'string' || !data.token.trim()) {
38
+ throw new NekoError('NoToken', 'Se requiere un token para utilizar la API');
39
+ }
23
40
  /**
24
41
  * url principal
25
42
  * @type {string}
26
43
  */
27
- this.baseURL = baseURL
44
+ this.baseURL = baseURL.replace(/\/$/, '')
28
45
  /**
29
46
  * data
30
47
  * @type {APIClientData}
@@ -34,32 +51,57 @@ class APIClient {
34
51
  /**
35
52
  * Obtiene el objeto compartido por la api a traves de la ruta y con autorizacion por token
36
53
  * @param {string} endpoint establece la ruta a la que se quiere comunicar
37
- * @returns {Promise<GifResponse>}
54
+ * @template {import('../typings').ApiResponse} [T=GifResponse]
55
+ * @returns {Promise<T>}
38
56
  * @throws {APIError} solo si sucede un error entre solicitudes
39
57
  */
40
- async get(endpoint) {
41
- try {
42
- const response = await (await fetch).default(`${this.baseURL}${endpoint}`, {
43
- method: 'GET',
44
- headers: {
45
- 'Authorization': `${this.data.token}`,
46
- 'Content-Type': 'application/json'
47
- }
48
- });
49
-
50
- if (!response.ok) {
51
- throw new APIError(endpoint, response, await response.json());
52
- }
53
-
54
- return response.json()
55
- } catch (error) {
56
- if (error instanceof APIError) {
57
- throw error;
58
- } else {
59
- throw new APIError(endpoint, { message: error.message }, {});
60
- }
61
- }
62
- }
63
- }
58
+ async get(endpoint, options = {}) {
59
+ if (typeof endpoint !== 'string' || !endpoint.startsWith('/')) {
60
+ throw new NekoError('InvalidEndpoint', 'El endpoint debe comenzar con "/"');
61
+ }
62
+
63
+ const timeout = Number.isFinite(options.timeout) && options.timeout > 0
64
+ ? options.timeout
65
+ : DEFAULT_TIMEOUT_MS;
66
+ const controller = new AbortController();
67
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
68
+
69
+ try {
70
+ const response = await fetch(`${this.baseURL}${endpoint}`, {
71
+ method: 'GET',
72
+ headers: {
73
+ 'Authorization': `${this.data.token}`,
74
+ 'Content-Type': 'application/json'
75
+ },
76
+ signal: controller.signal
77
+ });
78
+
79
+ const data = await readJson(response);
80
+ if (!response.ok) {
81
+ throw new APIError(endpoint, response, data);
82
+ }
83
+
84
+ return /** @type {T} */ (data)
85
+ } catch (error) {
86
+ if (error instanceof APIError) {
87
+ throw error;
88
+ }
89
+ const message = error?.name === 'AbortError'
90
+ ? `La solicitud excedió el tiempo límite de ${timeout} ms`
91
+ : error?.message ?? 'No fue posible conectar con la API';
92
+ throw APIError.fromNetworkError(endpoint, `${this.baseURL}${endpoint}`, message);
93
+ } finally {
94
+ clearTimeout(timeoutId);
95
+ }
96
+ }
97
+ }
98
+
99
+ async function readJson(response) {
100
+ try {
101
+ return await response.json();
102
+ } catch {
103
+ return { code: response.status, message: 'La API devolvió una respuesta que no es JSON' };
104
+ }
105
+ }
64
106
 
65
- module.exports = APIClient;
107
+ module.exports = APIClient;