@technomoron/apicore-client 1.0.0-beta.1

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,632 @@
1
+ /**
2
+ * Copyright (c) 2025 Bjørn Erik Jacobsen / Technomoron
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ function isPlainObject(value) {
8
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
9
+ }
10
+ function getErrorMessage(error) {
11
+ if (error instanceof Error) {
12
+ return error.message || undefined;
13
+ }
14
+ if (isPlainObject(error)) {
15
+ const potentialMessage = error['message'];
16
+ if (typeof potentialMessage === 'string') {
17
+ return potentialMessage;
18
+ }
19
+ }
20
+ return undefined;
21
+ }
22
+ function getErrorCode(error) {
23
+ if (isPlainObject(error)) {
24
+ const potentialCode = error['code'];
25
+ if (typeof potentialCode === 'string') {
26
+ return potentialCode;
27
+ }
28
+ }
29
+ return undefined;
30
+ }
31
+ function hasErrorName(error, expected) {
32
+ if (isPlainObject(error)) {
33
+ const potentialName = error['name'];
34
+ if (typeof potentialName === 'string') {
35
+ return potentialName === expected;
36
+ }
37
+ }
38
+ return false;
39
+ }
40
+ /**
41
+ * Parses an error message from various error types
42
+ * @param {unknown} error - The error to parse
43
+ * @param {string} def - Default message if no message can be extracted
44
+ * @returns {string} The extracted error message or default message
45
+ */
46
+ function parseMessage(error, def = '') {
47
+ if (typeof error === 'string') {
48
+ return error;
49
+ }
50
+ const message = getErrorMessage(error);
51
+ if (message) {
52
+ return message;
53
+ }
54
+ return def || '[Unknown error type - neither string, Error nor has .message]';
55
+ }
56
+ export class ApiResponse {
57
+ constructor(response = {}) {
58
+ this.success = false;
59
+ this.code = 500;
60
+ this.message = '[No Message]';
61
+ this.data = null;
62
+ this.errors = {};
63
+ this.success = response.success ?? false;
64
+ this.code = response.code ?? 500;
65
+ this.message = parseMessage(response.message, '[No Message]');
66
+ this.data = response.data ?? null;
67
+ this.errors = response.errors ?? {};
68
+ }
69
+ /**
70
+ * Creates a successful response with the provided data
71
+ * @param {T} data - The response data
72
+ * @param {Partial<ApiResponseData<T>>} overrides - Optional overrides for response properties
73
+ * @returns {ApiResponse<T>} A configured successful response
74
+ */
75
+ static ok(data, overrides = {}) {
76
+ return new ApiResponse({
77
+ success: true,
78
+ code: 200,
79
+ message: 'OK',
80
+ data,
81
+ ...overrides
82
+ });
83
+ }
84
+ /**
85
+ * Creates an error response with the provided message
86
+ * @param {unknown} messageOrError - The error message, error object, or existing ApiResponse
87
+ * @param {Partial<ApiResponseData<T>>} overrides - Optional overrides for response properties
88
+ * @returns {ApiResponse<T>} A configured error response
89
+ */
90
+ static error(messageOrError, overrides = {}) {
91
+ if (messageOrError instanceof ApiResponse) {
92
+ return messageOrError;
93
+ }
94
+ // Use parseMessage to handle any error type consistently
95
+ const message = parseMessage(messageOrError, 'Unknown error');
96
+ return new ApiResponse({
97
+ success: false,
98
+ code: overrides.code ?? 500,
99
+ message,
100
+ data: null,
101
+ ...overrides
102
+ });
103
+ }
104
+ /**
105
+ * Adds a named error to the errors collection
106
+ * @param {string} name - The name/key for the error
107
+ * @param {string} value - The error message
108
+ */
109
+ addError(name, value) {
110
+ this.errors[name] = value;
111
+ }
112
+ /**
113
+ * Type guard to check if the response is successful and contains data
114
+ * When this returns true, TypeScript narrows the type to ensure data is not null
115
+ * @returns {boolean} True if the response is successful and contains data
116
+ */
117
+ isSuccess() {
118
+ return this.success && this.data !== null;
119
+ }
120
+ }
121
+ class ApiClient {
122
+ constructor(apiUrl, config = {}) {
123
+ this.apiKey = null;
124
+ this.aToken = null;
125
+ this.rToken = null;
126
+ // Shared promise so that concurrent 401s all await the same refresh attempt.
127
+ this._refreshPromise = null;
128
+ this.timeoutMs = 15000;
129
+ this.maxRetries = 1;
130
+ this.debug = false;
131
+ this.tokens = true;
132
+ this.apiUrl = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
133
+ this.apiKey = config.apiKey ?? null;
134
+ this.timeoutMs = config.timeout ?? 15000;
135
+ this.maxRetries = config.maxRetries ?? 1;
136
+ this.debug = config.debug ?? false;
137
+ this.tokens = config.enableTokens ?? true;
138
+ }
139
+ /**
140
+ * Updates the client configuration
141
+ * @param {Partial<ApiClientConfig>} config - Configuration updates
142
+ */
143
+ configure(config) {
144
+ if (config.apiKey !== undefined)
145
+ this.apiKey = config.apiKey;
146
+ if (config.timeout !== undefined && config.timeout !== null)
147
+ this.timeoutMs = config.timeout;
148
+ if (config.maxRetries !== undefined && config.maxRetries !== null)
149
+ this.maxRetries = config.maxRetries;
150
+ if (config.debug !== undefined && config.debug !== null)
151
+ this.debug = config.debug;
152
+ if (config.enableTokens !== undefined && config.enableTokens !== null)
153
+ this.tokens = config.enableTokens;
154
+ }
155
+ /**
156
+ * Gets the current configuration
157
+ * @returns {ApiClientConfig} Current configuration
158
+ */
159
+ getConfig() {
160
+ return {
161
+ // Omit the raw API key — return a boolean sentinel so callers can check
162
+ // whether a key is configured without being able to extract its value.
163
+ apiKey: this.apiKey ? '***' : null,
164
+ timeout: this.timeoutMs,
165
+ maxRetries: this.maxRetries,
166
+ debug: this.debug,
167
+ enableTokens: this.tokens
168
+ };
169
+ }
170
+ tryStringify(obj) {
171
+ try {
172
+ return JSON.stringify(obj);
173
+ }
174
+ catch (err) {
175
+ if (this.debug) {
176
+ console.error('[ApiClient] JSON.stringify failed:', err);
177
+ }
178
+ throw ApiResponse.error('Failed to serialize request body', {
179
+ code: 400
180
+ });
181
+ }
182
+ }
183
+ /**
184
+ * Retrieves the stored access token
185
+ * Designed to be overridden in subclasses for custom token storage
186
+ * @returns {string|null} The current access token or null if not set
187
+ */
188
+ get_access_token() {
189
+ return this.aToken;
190
+ }
191
+ /**
192
+ * Stores the access token
193
+ * Designed to be overridden in subclasses for custom token storage
194
+ * @param {string|null} access - The access token to store, or null to clear
195
+ */
196
+ set_access_token(access) {
197
+ this.aToken = access;
198
+ }
199
+ /**
200
+ * Retrieves the stored refresh token
201
+ * Designed to be overridden in subclasses for custom token storage
202
+ * @returns {string|null} The current refresh token or null if not set
203
+ */
204
+ get_refresh_token() {
205
+ return this.rToken;
206
+ }
207
+ /**
208
+ * Stores the refresh token
209
+ * Designed to be overridden in subclasses for custom token storage
210
+ * @param {string|null} refresh - The refresh token to store, or null to clear
211
+ */
212
+ set_refresh_token(refresh) {
213
+ this.rToken = refresh;
214
+ }
215
+ /**
216
+ * Sets the API key for authentication
217
+ * Designed to be overridden in subclasses for custom API key storage
218
+ * @param {string|null} apikey - The API key to use for authentication, or null to clear
219
+ */
220
+ set_apikey(apikey) {
221
+ this.apiKey = apikey;
222
+ }
223
+ /**
224
+ * Retrieves the stored API key
225
+ * Designed to be overridden in subclasses for custom API key storage
226
+ * @returns {string|null} The current API key or null if not set
227
+ */
228
+ get_apikey() {
229
+ return this.apiKey;
230
+ }
231
+ /**
232
+ * Builds authentication headers based on current configuration
233
+ * @returns {Record<string, string>} Headers object with authentication if available
234
+ */
235
+ buildAuthHeaders() {
236
+ const headers = {};
237
+ if (this.apiKey) {
238
+ headers.Authorization = `Bearer ${this.apiKey}`;
239
+ }
240
+ else if (this.tokens) {
241
+ const accessToken = this.get_access_token();
242
+ if (accessToken) {
243
+ headers.Authorization = `Bearer ${accessToken}`;
244
+ }
245
+ }
246
+ return headers;
247
+ }
248
+ /**
249
+ * A drop‑in replacement for fetch() that handles timeouts and
250
+ * normalized network errors and throws an ApiResponse on failure.
251
+ * Uses the instance's configured timeout value.
252
+ */
253
+ async safeFetch(input, init = {}) {
254
+ const controller = new AbortController();
255
+ const id = setTimeout(() => controller.abort(), this.timeoutMs);
256
+ const headers = new Headers(init.headers);
257
+ const authHeaders = this.buildAuthHeaders();
258
+ for (const [k, v] of Object.entries(authHeaders)) {
259
+ headers.set(k, v);
260
+ }
261
+ const method = (init.method ?? 'GET').toUpperCase();
262
+ if (method !== 'GET' &&
263
+ init.body !== undefined &&
264
+ init.body !== null &&
265
+ !headers.has('Content-Type') &&
266
+ !(init.body instanceof FormData)) {
267
+ headers.set('Content-Type', 'application/json');
268
+ }
269
+ if (this.debug) {
270
+ console.log('[safeFetch] Request:', input);
271
+ console.log('[safeFetch] Method:', method);
272
+ console.log('[safeFetch] Headers:', headers);
273
+ }
274
+ try {
275
+ const res = await fetch(input, {
276
+ ...init,
277
+ headers,
278
+ signal: controller.signal
279
+ });
280
+ return res;
281
+ }
282
+ catch (err) {
283
+ if (hasErrorName(err, 'AbortError')) {
284
+ throw ApiResponse.error('Fetch timed out', { code: 504 });
285
+ }
286
+ let reason = '';
287
+ let status = 500;
288
+ const isBrowser = typeof window !== 'undefined' &&
289
+ typeof window.document !== 'undefined';
290
+ if (isBrowser) {
291
+ // Browser environment
292
+ if (err instanceof TypeError && err.message === 'Failed to fetch') {
293
+ reason = 'Possible CORS issue or server unreachable';
294
+ status = 503;
295
+ }
296
+ else {
297
+ reason = getErrorMessage(err) || 'Unknown browser fetch error';
298
+ }
299
+ }
300
+ else {
301
+ // Node.js environment
302
+ switch (getErrorCode(err)) {
303
+ case 'ECONNREFUSED':
304
+ reason = 'Connection refused by server';
305
+ status = 503;
306
+ break;
307
+ case 'ENOTFOUND':
308
+ reason = 'DNS lookup failed - host not found';
309
+ status = 503;
310
+ break;
311
+ case 'ETIMEDOUT':
312
+ reason = 'Connection timed out';
313
+ status = 504;
314
+ break;
315
+ case 'ECONNRESET':
316
+ reason = 'Connection reset by server';
317
+ status = 503;
318
+ break;
319
+ case 'EHOSTUNREACH':
320
+ reason = 'Host unreachable';
321
+ status = 503;
322
+ break;
323
+ default:
324
+ reason = getErrorMessage(err) || 'Unknown network error';
325
+ }
326
+ }
327
+ throw ApiResponse.error(`Fetch failed: ${reason}`, { code: status });
328
+ }
329
+ finally {
330
+ clearTimeout(id);
331
+ }
332
+ }
333
+ /**
334
+ * Safely parses JSON response with proper error handling
335
+ * @param {Response} response - The fetch response
336
+ * @returns {Promise<RawApiResponse<T>>} The parsed JSON data
337
+ */
338
+ async parseJsonResponse(response) {
339
+ try {
340
+ return (await response.json());
341
+ }
342
+ catch (err) {
343
+ throw ApiResponse.error(`Failed to parse JSON response: ${parseMessage(err)}`, {
344
+ code: response.status
345
+ });
346
+ }
347
+ }
348
+ async login(username, password, domain = '', fingerprint = '') {
349
+ try {
350
+ const loginBody = { login: username, password };
351
+ if (domain)
352
+ loginBody.domain = domain;
353
+ if (fingerprint)
354
+ loginBody.fingerprint = fingerprint;
355
+ const response = await this.safeFetch(`${this.apiUrl}/api/auth/v1/login`, {
356
+ method: 'POST',
357
+ body: this.tryStringify(loginBody),
358
+ credentials: 'include'
359
+ });
360
+ const jsondata = await this.parseJsonResponse(response);
361
+ if (this.debug) {
362
+ console.log('Login Response Headers:');
363
+ response.headers.forEach((value, name) => {
364
+ console.log(`${name}: ${value}`);
365
+ });
366
+ }
367
+ if (response.ok) {
368
+ // Type-safe access to token data — only update tokens when explicitly present
369
+ const tokenData = jsondata.data;
370
+ if (tokenData?.accessToken !== undefined) {
371
+ this.set_access_token(tokenData.accessToken ?? null);
372
+ }
373
+ if (tokenData?.refreshToken !== undefined) {
374
+ this.set_refresh_token(tokenData.refreshToken ?? null);
375
+ }
376
+ return ApiResponse.ok(jsondata.data, {
377
+ message: jsondata.message || 'Login successful',
378
+ code: response.status
379
+ });
380
+ }
381
+ else {
382
+ return ApiResponse.error(jsondata.message || 'Login Failed', {
383
+ code: response.status,
384
+ errors: jsondata.errors || {}
385
+ });
386
+ }
387
+ }
388
+ catch (e) {
389
+ if (e instanceof ApiResponse) {
390
+ return e;
391
+ }
392
+ return ApiResponse.error(e);
393
+ }
394
+ }
395
+ async logout(token = '') {
396
+ try {
397
+ // Use the caller-supplied token; fall back to the stored refresh token so
398
+ // that cookieless environments (Node.js, no cookie jar) still revoke the token.
399
+ const refreshToken = token || this.get_refresh_token() || '';
400
+ const response = await this.safeFetch(`${this.apiUrl}/api/auth/v1/logout`, {
401
+ method: 'POST',
402
+ body: refreshToken ? this.tryStringify({ token: refreshToken }) : '{}',
403
+ credentials: 'include'
404
+ });
405
+ const jsondata = await this.parseJsonResponse(response);
406
+ if (this.debug) {
407
+ console.log('Logout Response Headers:');
408
+ response.headers.forEach((value, name) => {
409
+ console.log(`${name}: ${value}`);
410
+ });
411
+ }
412
+ // Always clear local tokens on logout attempt, regardless of server response
413
+ this.set_access_token(null);
414
+ this.set_refresh_token(null);
415
+ if (!response.ok) {
416
+ return ApiResponse.error(jsondata.message || 'Logout Failed', {
417
+ code: response.status,
418
+ errors: jsondata.errors || {}
419
+ });
420
+ }
421
+ return ApiResponse.ok(jsondata.data, {
422
+ message: jsondata.message || 'Logout Successful',
423
+ code: response.status
424
+ });
425
+ }
426
+ catch (e) {
427
+ if (e instanceof ApiResponse) {
428
+ return e;
429
+ }
430
+ return ApiResponse.error(e);
431
+ }
432
+ }
433
+ async refreshAccessToken() {
434
+ // Serialise concurrent refresh attempts: if a refresh is already in flight,
435
+ // await it instead of launching a second one (which would fail because the
436
+ // server rotates the refresh token on every use).
437
+ if (this._refreshPromise) {
438
+ return this._refreshPromise;
439
+ }
440
+ this._refreshPromise = this.performRefresh();
441
+ try {
442
+ return (await this._refreshPromise);
443
+ }
444
+ finally {
445
+ this._refreshPromise = null;
446
+ }
447
+ }
448
+ async performRefresh() {
449
+ try {
450
+ if (!this.get_refresh_token()) {
451
+ return ApiResponse.error('No Refresh Token Available', { code: 401 });
452
+ }
453
+ const response = await this.safeFetch(`${this.apiUrl}/api/auth/v1/refresh`, {
454
+ method: 'POST',
455
+ body: this.tryStringify({ refreshToken: this.get_refresh_token() }),
456
+ credentials: 'include'
457
+ });
458
+ const jsondata = await this.parseJsonResponse(response);
459
+ if (!response.ok) {
460
+ // Clear stale tokens when the server rejects the refresh token.
461
+ if (response.status === 401 || response.status === 403) {
462
+ this.set_access_token(null);
463
+ this.set_refresh_token(null);
464
+ }
465
+ return ApiResponse.error(jsondata.message || 'Token Refresh Failed', {
466
+ code: response.status,
467
+ errors: jsondata.errors || {}
468
+ });
469
+ }
470
+ // Only update tokens when the field is explicitly present in the response.
471
+ const tokenData = jsondata.data;
472
+ if (tokenData?.accessToken !== undefined) {
473
+ this.set_access_token(tokenData.accessToken ?? null);
474
+ }
475
+ if (tokenData?.refreshToken !== undefined) {
476
+ this.set_refresh_token(tokenData.refreshToken ?? null);
477
+ }
478
+ return ApiResponse.ok(jsondata.data, {
479
+ message: jsondata.message || 'Token Refreshed',
480
+ code: response.status
481
+ });
482
+ }
483
+ catch (e) {
484
+ if (e instanceof ApiResponse) {
485
+ return e;
486
+ }
487
+ return ApiResponse.error(e);
488
+ }
489
+ }
490
+ async fetchWithRetry(url, init = {}) {
491
+ let allowAuthRetry = true;
492
+ // Iterative retry loop to avoid deep call stack
493
+ for (let timeoutRetryCount = 0; timeoutRetryCount <= this.maxRetries; timeoutRetryCount++) {
494
+ try {
495
+ const response = await this.safeFetch(url, {
496
+ ...init,
497
+ credentials: 'include'
498
+ });
499
+ // Parse JSON with type safety
500
+ const payload = await this.parseJsonResponse(response);
501
+ // Success case (HTTP layer)
502
+ // If the API uses a JSON { success: false } shape even on 2xx, respect it.
503
+ if (response.ok) {
504
+ if (payload.success === false) {
505
+ return ApiResponse.error(payload.message || 'Request failed', {
506
+ code: payload.code ?? response.status,
507
+ errors: payload.errors || {}
508
+ });
509
+ }
510
+ return ApiResponse.ok(payload.data, {
511
+ message: payload.message || 'OK',
512
+ code: payload.code ?? response.status,
513
+ errors: payload.errors || {}
514
+ });
515
+ }
516
+ // If using API key, don't retry on auth failures
517
+ if (this.apiKey) {
518
+ return ApiResponse.error(payload.message || 'Request failed with API key authentication', {
519
+ code: response.status,
520
+ errors: payload.errors || {}
521
+ });
522
+ }
523
+ // Try to refresh token on 401 (only once)
524
+ if (allowAuthRetry && response.status === 401 && this.get_refresh_token()) {
525
+ if (this.debug) {
526
+ console.log('Received 401, attempting token refresh...');
527
+ }
528
+ const refreshRes = await this.refreshAccessToken();
529
+ if (!refreshRes.success) {
530
+ return ApiResponse.error(refreshRes.message, {
531
+ code: refreshRes.code,
532
+ errors: refreshRes.errors
533
+ });
534
+ }
535
+ // Allow one more iteration but disable further auth retries
536
+ timeoutRetryCount = 0;
537
+ allowAuthRetry = false;
538
+ continue; // Retry with new token
539
+ }
540
+ // Final fallback error
541
+ return ApiResponse.error(payload.message || 'Unable to serve request', {
542
+ code: response.status,
543
+ errors: payload.errors || {}
544
+ });
545
+ }
546
+ catch (err) {
547
+ // Handle thrown ApiResponse errors from safeFetch or parseJsonResponse
548
+ if (err instanceof ApiResponse) {
549
+ // Check if this is a timeout error and we can retry
550
+ if (err.code === 504 && timeoutRetryCount < this.maxRetries) {
551
+ if (this.debug) {
552
+ console.log(`Request timed out, retrying... (attempt ${timeoutRetryCount + 1}/${this.maxRetries + 1})`);
553
+ }
554
+ continue; // Retry
555
+ }
556
+ return err; // Return error
557
+ }
558
+ return ApiResponse.error(err);
559
+ }
560
+ }
561
+ // This should never be reached, but TypeScript needs it
562
+ return ApiResponse.error('Maximum retries exceeded', { code: 500 });
563
+ }
564
+ /**
565
+ * Performs a generic HTTP request to the API
566
+ * @param {'GET'|'POST'|'PUT'|'DELETE'} method - The HTTP method to use
567
+ * @param {string} command - The API endpoint path
568
+ * @param {SerializableBody} [body] - Optional request body (for POST/PUT/DELETE requests)
569
+ * @returns {Promise<ApiResponse<T>>} A promise resolving to the API response
570
+ * @template T - The expected type of the response data
571
+ */
572
+ async request(method, command, body, files) {
573
+ const url = `${this.apiUrl}${command}`;
574
+ const options = {
575
+ method
576
+ };
577
+ if (files && files.length > 0) {
578
+ const form = new FormData();
579
+ if (body !== undefined && body !== null) {
580
+ if (isPlainObject(body)) {
581
+ for (const [key, value] of Object.entries(body)) {
582
+ form.append(key, typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value));
583
+ }
584
+ }
585
+ else {
586
+ form.append('payload', this.tryStringify(body));
587
+ }
588
+ }
589
+ for (const fileEntry of files) {
590
+ if (Array.isArray(fileEntry)) {
591
+ const [name, file] = fileEntry;
592
+ form.append(name, file);
593
+ }
594
+ else {
595
+ form.append('files', fileEntry); // default field name
596
+ }
597
+ }
598
+ options.body = form;
599
+ }
600
+ else if (body !== undefined) {
601
+ try {
602
+ options.body = this.tryStringify(body);
603
+ }
604
+ catch (e) {
605
+ return ApiResponse.error(e);
606
+ }
607
+ }
608
+ if (this.debug) {
609
+ console.log(`Making ${method} request to: ${url} (timeout: ${this.timeoutMs}ms, max retries: ${this.maxRetries})`);
610
+ if (body !== undefined) {
611
+ console.log('Request body:', options.body);
612
+ }
613
+ }
614
+ return this.fetchWithRetry(url, options);
615
+ }
616
+ async get(command) {
617
+ return this.request('GET', command);
618
+ }
619
+ async post(command, body, files) {
620
+ return this.request('POST', command, body, files);
621
+ }
622
+ async put(command, body) {
623
+ return this.request('PUT', command, body);
624
+ }
625
+ async delete(command, body) {
626
+ return this.request('DELETE', command, body);
627
+ }
628
+ async ping() {
629
+ return this.get('/api/v1/ping');
630
+ }
631
+ }
632
+ export default ApiClient;
@@ -0,0 +1,4 @@
1
+ export { default as ApiClient, ApiResponse } from './apicore-client.js';
2
+ export { default } from './apicore-client.js';
3
+ export type { maybeFile } from './apicore-client.js';
4
+ export type { ApiClientConfig, ApiResponseData, AuthIdentifier, AuthTokenData, LogoutResponseData, PingResponseData, SafeUser, WhoAmIResponseData } from './apicore-client.js';
@@ -0,0 +1,2 @@
1
+ export { default as ApiClient, ApiResponse } from './apicore-client.js';
2
+ export { default } from './apicore-client.js';