pulse-js-framework 1.7.29 → 1.7.31

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.
@@ -7,23 +7,15 @@ import { pulse, computed, batch, effect, onCleanup } from './pulse.js';
7
7
  import { createHttp, HttpError } from './http.js';
8
8
  import { createWebSocket, WebSocketError } from './websocket.js';
9
9
  import { createVersionedAsync } from './async.js';
10
- import { RuntimeError, createErrorMessage, getDocsUrl } from './errors.js';
10
+ import { ClientError } from './errors.js';
11
+ import { LRUCache } from './lru-cache.js';
12
+ import { InterceptorManager } from './interceptor-manager.js';
13
+ import { onWindowFocus, onWindowOnline } from './utils.js';
11
14
 
12
15
  // ============================================================================
13
16
  // Constants
14
17
  // ============================================================================
15
18
 
16
- const GRAPHQL_SUGGESTIONS = {
17
- GRAPHQL_ERROR: 'Check the GraphQL errors array for specific field errors.',
18
- NETWORK_ERROR: 'Verify network connectivity and GraphQL endpoint URL.',
19
- PARSE_ERROR: 'The response was not valid GraphQL JSON. Check server configuration.',
20
- TIMEOUT: 'Request timed out. Consider increasing timeout or optimizing query.',
21
- AUTHENTICATION_ERROR: 'Authentication required. Check your credentials or token.',
22
- AUTHORIZATION_ERROR: 'Insufficient permissions for this operation.',
23
- VALIDATION_ERROR: 'Invalid input provided. Check variables match schema types.',
24
- SUBSCRIPTION_ERROR: 'WebSocket subscription failed. Check connection status.'
25
- };
26
-
27
19
  /**
28
20
  * graphql-ws protocol message types
29
21
  * @see https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md
@@ -48,10 +40,25 @@ const MessageType = {
48
40
  // ============================================================================
49
41
 
50
42
  /**
51
- * Error class for GraphQL operations
52
- * @extends RuntimeError
43
+ * Error class for GraphQL operations.
44
+ * Extends ClientError for consistent error handling patterns.
53
45
  */
54
- export class GraphQLError extends RuntimeError {
46
+ export class GraphQLError extends ClientError {
47
+ static suggestions = {
48
+ GRAPHQL_ERROR: 'Check the GraphQL errors array for specific field errors.',
49
+ NETWORK_ERROR: 'Verify network connectivity and GraphQL endpoint URL.',
50
+ PARSE_ERROR: 'The response was not valid GraphQL JSON. Check server configuration.',
51
+ TIMEOUT: 'Request timed out. Consider increasing timeout or optimizing query.',
52
+ AUTHENTICATION_ERROR: 'Authentication required. Check your credentials or token.',
53
+ AUTHORIZATION_ERROR: 'Insufficient permissions for this operation.',
54
+ VALIDATION_ERROR: 'Invalid input provided. Check variables match schema types.',
55
+ SUBSCRIPTION_ERROR: 'WebSocket subscription failed. Check connection status.'
56
+ };
57
+
58
+ static errorName = 'GraphQLError';
59
+ static defaultCode = 'GRAPHQL_ERROR';
60
+ static markerProperty = 'isGraphQLError';
61
+
55
62
  /**
56
63
  * @param {string} message - Error message
57
64
  * @param {Object} [options={}] - Error options
@@ -63,17 +70,7 @@ export class GraphQLError extends RuntimeError {
63
70
  * @param {Object} [options.request] - Request configuration
64
71
  */
65
72
  constructor(message, options = {}) {
66
- const suggestion = GRAPHQL_SUGGESTIONS[options.code] || GRAPHQL_SUGGESTIONS.GRAPHQL_ERROR;
67
- super(
68
- createErrorMessage({
69
- code: options.code || 'GRAPHQL_ERROR',
70
- message,
71
- suggestion
72
- }),
73
- { code: options.code || 'GRAPHQL_ERROR', suggestion }
74
- );
75
-
76
- this.name = 'GraphQLError';
73
+ super(message, options);
77
74
  this.errors = options.errors || [];
78
75
  this.data = options.data ?? null;
79
76
  this.extensions = options.extensions || {};
@@ -87,7 +84,7 @@ export class GraphQLError extends RuntimeError {
87
84
  * @returns {boolean}
88
85
  */
89
86
  static isGraphQLError(error) {
90
- return error instanceof GraphQLError;
87
+ return error?.isGraphQLError === true || error instanceof GraphQLError;
91
88
  }
92
89
 
93
90
  /**
@@ -125,22 +122,6 @@ export class GraphQLError extends RuntimeError {
125
122
  this.errors.some(e => e.extensions?.code === 'BAD_USER_INPUT');
126
123
  }
127
124
 
128
- /**
129
- * Check if this is a network error
130
- * @returns {boolean}
131
- */
132
- isNetworkError() {
133
- return this.code === 'NETWORK_ERROR';
134
- }
135
-
136
- /**
137
- * Check if this is a timeout error
138
- * @returns {boolean}
139
- */
140
- isTimeout() {
141
- return this.code === 'TIMEOUT';
142
- }
143
-
144
125
  /**
145
126
  * Get the first error message from GraphQL errors
146
127
  * @returns {string|null}
@@ -231,60 +212,6 @@ function generateCacheKey(query, variables) {
231
212
  return `gql:${queryHash}${variablesHash ? ':' + variablesHash : ''}`;
232
213
  }
233
214
 
234
- // ============================================================================
235
- // Interceptor Manager
236
- // ============================================================================
237
-
238
- /**
239
- * Manages request/response interceptors
240
- */
241
- class InterceptorManager {
242
- #handlers = new Map();
243
- #nextId = 0;
244
-
245
- /**
246
- * Add an interceptor
247
- * @param {Function} fulfilled - Success handler
248
- * @param {Function} [rejected] - Error handler
249
- * @returns {number} Interceptor ID
250
- */
251
- use(fulfilled, rejected) {
252
- const id = this.#nextId++;
253
- this.#handlers.set(id, { fulfilled, rejected });
254
- return id;
255
- }
256
-
257
- /**
258
- * Remove an interceptor
259
- * @param {number} id - Interceptor ID
260
- */
261
- eject(id) {
262
- this.#handlers.delete(id);
263
- }
264
-
265
- /**
266
- * Clear all interceptors
267
- */
268
- clear() {
269
- this.#handlers.clear();
270
- }
271
-
272
- /**
273
- * Get handler count
274
- * @returns {number}
275
- */
276
- get size() {
277
- return this.#handlers.size;
278
- }
279
-
280
- /**
281
- * Iterate over handlers
282
- */
283
- [Symbol.iterator]() {
284
- return this.#handlers.values();
285
- }
286
- }
287
-
288
215
  // ============================================================================
289
216
  // Subscription Manager
290
217
  // ============================================================================
@@ -503,7 +430,8 @@ class GraphQLClient {
503
430
  #subscriptionManager = null;
504
431
  #options;
505
432
  #inflightQueries = new Map();
506
- #cache = new Map();
433
+ // LRU cache to prevent unbounded memory growth (default 500 entries)
434
+ #cache;
507
435
 
508
436
  /**
509
437
  * Request interceptors
@@ -536,12 +464,16 @@ class GraphQLClient {
536
464
  wsMaxRetries: options.wsMaxRetries ?? 5,
537
465
  cache: options.cache ?? true,
538
466
  cacheTime: options.cacheTime ?? 300000,
467
+ cacheMaxSize: options.cacheMaxSize ?? 500,
539
468
  staleTime: options.staleTime ?? 0,
540
469
  dedupe: options.dedupe ?? true,
541
470
  throwOnError: options.throwOnError ?? true,
542
471
  onError: options.onError
543
472
  };
544
473
 
474
+ // Initialize LRU cache to prevent unbounded memory growth
475
+ this.#cache = new LRUCache(this.#options.cacheMaxSize);
476
+
545
477
  // Create HTTP client for queries/mutations
546
478
  this.#http = createHttp({
547
479
  baseURL: '',
@@ -863,6 +795,7 @@ class GraphQLClient {
863
795
  * @param {number} [options.wsMaxRetries=5] - Max WebSocket reconnection attempts
864
796
  * @param {boolean} [options.cache=true] - Enable query caching
865
797
  * @param {number} [options.cacheTime=300000] - Cache TTL in ms
798
+ * @param {number} [options.cacheMaxSize=500] - Maximum cache entries (LRU eviction)
866
799
  * @param {number} [options.staleTime=0] - Stale threshold in ms
867
800
  * @param {boolean} [options.dedupe=true] - Deduplicate identical in-flight queries
868
801
  * @param {boolean} [options.throwOnError=true] - Throw on GraphQL errors
@@ -1047,21 +980,13 @@ export function useQuery(query, variables, options = {}) {
1047
980
  }
1048
981
 
1049
982
  // Setup window focus listener
1050
- if (options.refetchOnFocus && typeof window !== 'undefined') {
1051
- const handleFocus = () => {
1052
- if (isEnabled()) executeQuery();
1053
- };
1054
- window.addEventListener('focus', handleFocus);
1055
- onCleanup(() => window.removeEventListener('focus', handleFocus));
983
+ if (options.refetchOnFocus) {
984
+ onWindowFocus(() => { if (isEnabled()) executeQuery(); }, onCleanup);
1056
985
  }
1057
986
 
1058
987
  // Setup online listener
1059
- if (options.refetchOnReconnect && typeof window !== 'undefined') {
1060
- const handleOnline = () => {
1061
- if (isEnabled()) executeQuery();
1062
- };
1063
- window.addEventListener('online', handleOnline);
1064
- onCleanup(() => window.removeEventListener('online', handleOnline));
988
+ if (options.refetchOnReconnect) {
989
+ onWindowOnline(() => { if (isEnabled()) executeQuery(); }, onCleanup);
1065
990
  }
1066
991
 
1067
992
  return {
@@ -1221,6 +1146,9 @@ export function useMutation(mutation, options = {}) {
1221
1146
  * @param {Function} [options.onError] - Error callback
1222
1147
  * @param {Function} [options.onComplete] - Called when subscription ends
1223
1148
  * @param {boolean} [options.shouldResubscribe=true] - Resubscribe on error
1149
+ * @param {number} [options.retryBaseDelay=1000] - Base delay for exponential backoff (ms)
1150
+ * @param {number} [options.retryMaxDelay=30000] - Maximum delay between retries (ms)
1151
+ * @param {number} [options.maxRetries=Infinity] - Maximum number of retry attempts
1224
1152
  * @returns {Object} Subscription result with reactive state
1225
1153
  */
1226
1154
  export function useSubscription(subscription, variables, options = {}) {
@@ -1229,8 +1157,30 @@ export function useSubscription(subscription, variables, options = {}) {
1229
1157
  const data = pulse(null);
1230
1158
  const error = pulse(null);
1231
1159
  const status = pulse('connecting');
1160
+ const retryCount = pulse(0);
1232
1161
 
1233
1162
  let unsubscribeFn = null;
1163
+ let retryTimeoutId = null;
1164
+
1165
+ // Backoff configuration
1166
+ const retryBaseDelay = options.retryBaseDelay ?? 1000;
1167
+ const retryMaxDelay = options.retryMaxDelay ?? 30000;
1168
+ const maxRetries = options.maxRetries ?? Infinity;
1169
+
1170
+ /**
1171
+ * Calculate delay with exponential backoff and jitter
1172
+ * @param {number} attempt - Current retry attempt (0-indexed)
1173
+ * @returns {number} Delay in milliseconds
1174
+ */
1175
+ function calculateBackoffDelay(attempt) {
1176
+ // Exponential backoff: baseDelay * 2^attempt
1177
+ const exponentialDelay = retryBaseDelay * Math.pow(2, attempt);
1178
+ // Cap at max delay
1179
+ const cappedDelay = Math.min(exponentialDelay, retryMaxDelay);
1180
+ // Add jitter (±25%) to prevent thundering herd
1181
+ const jitter = cappedDelay * 0.25 * (Math.random() * 2 - 1);
1182
+ return Math.max(0, cappedDelay + jitter);
1183
+ }
1234
1184
 
1235
1185
  // Resolve variables
1236
1186
  const resolveVariables = () => {
@@ -1260,6 +1210,8 @@ export function useSubscription(subscription, variables, options = {}) {
1260
1210
  onData: (payload) => {
1261
1211
  status.set('connected');
1262
1212
  data.set(payload);
1213
+ // Reset retry count on successful data
1214
+ retryCount.set(0);
1263
1215
  options.onData?.(payload);
1264
1216
  },
1265
1217
  onError: (err) => {
@@ -1267,10 +1219,21 @@ export function useSubscription(subscription, variables, options = {}) {
1267
1219
  status.set('error');
1268
1220
  options.onError?.(err);
1269
1221
 
1270
- // Resubscribe on error if enabled
1222
+ // Resubscribe on error if enabled and under max retries
1271
1223
  if (options.shouldResubscribe !== false) {
1272
- unsubscribeFn = null;
1273
- setTimeout(() => subscribe(), 1000);
1224
+ const currentRetry = retryCount.peek();
1225
+ if (currentRetry < maxRetries) {
1226
+ unsubscribeFn = null;
1227
+ const delay = calculateBackoffDelay(currentRetry);
1228
+ retryCount.set(currentRetry + 1);
1229
+ status.set('reconnecting');
1230
+ retryTimeoutId = setTimeout(() => {
1231
+ retryTimeoutId = null;
1232
+ subscribe();
1233
+ }, delay);
1234
+ } else {
1235
+ status.set('failed');
1236
+ }
1274
1237
  }
1275
1238
  },
1276
1239
  onComplete: () => {
@@ -1285,11 +1248,17 @@ export function useSubscription(subscription, variables, options = {}) {
1285
1248
  * Unsubscribe
1286
1249
  */
1287
1250
  function unsubscribe() {
1251
+ // Cancel pending retry
1252
+ if (retryTimeoutId) {
1253
+ clearTimeout(retryTimeoutId);
1254
+ retryTimeoutId = null;
1255
+ }
1288
1256
  if (unsubscribeFn) {
1289
1257
  unsubscribeFn();
1290
1258
  unsubscribeFn = null;
1291
1259
  status.set('closed');
1292
1260
  }
1261
+ retryCount.set(0);
1293
1262
  }
1294
1263
 
1295
1264
  /**
@@ -1325,6 +1294,7 @@ export function useSubscription(subscription, variables, options = {}) {
1325
1294
  data,
1326
1295
  error,
1327
1296
  status,
1297
+ retryCount,
1328
1298
  unsubscribe,
1329
1299
  resubscribe
1330
1300
  };
package/runtime/http.js CHANGED
@@ -5,27 +5,30 @@
5
5
 
6
6
  import { pulse, computed, batch } from './pulse.js';
7
7
  import { useAsync, useResource } from './async.js';
8
- import { RuntimeError, createErrorMessage, getDocsUrl } from './errors.js';
8
+ import { ClientError } from './errors.js';
9
+ import { InterceptorManager } from './interceptor-manager.js';
9
10
 
10
11
  // ============================================================================
11
12
  // HTTP Error Class
12
13
  // ============================================================================
13
14
 
14
15
  /**
15
- * HTTP-specific error suggestions
16
+ * HTTP Error with request/response context.
17
+ * Extends ClientError for consistent error handling patterns.
16
18
  */
17
- const HTTP_SUGGESTIONS = {
18
- TIMEOUT: 'Consider increasing the timeout or checking network conditions.',
19
- NETWORK: 'Check internet connectivity and ensure the server is reachable.',
20
- ABORT: 'Request was cancelled. This is usually intentional.',
21
- HTTP_ERROR: 'Check the response status and server logs for details.',
22
- PARSE_ERROR: 'The response could not be parsed. Check the Content-Type header.'
23
- };
19
+ export class HttpError extends ClientError {
20
+ static suggestions = {
21
+ TIMEOUT: 'Consider increasing the timeout or checking network conditions.',
22
+ NETWORK: 'Check internet connectivity and ensure the server is reachable.',
23
+ ABORT: 'Request was cancelled. This is usually intentional.',
24
+ HTTP_ERROR: 'Check the response status and server logs for details.',
25
+ PARSE_ERROR: 'The response could not be parsed. Check the Content-Type header.'
26
+ };
27
+
28
+ static errorName = 'HttpError';
29
+ static defaultCode = 'HTTP_ERROR';
30
+ static markerProperty = 'isHttpError';
24
31
 
25
- /**
26
- * HTTP Error with request/response context
27
- */
28
- export class HttpError extends RuntimeError {
29
32
  /**
30
33
  * @param {string} message - Error message
31
34
  * @param {Object} [options={}] - Error options
@@ -35,50 +38,22 @@ export class HttpError extends RuntimeError {
35
38
  * @param {Object} [options.response] - The HttpResponse object
36
39
  */
37
40
  constructor(message, options = {}) {
38
- const code = options.code || 'HTTP_ERROR';
39
- const formattedMessage = createErrorMessage({
40
- code,
41
- message,
42
- context: options.context,
43
- suggestion: options.suggestion || HTTP_SUGGESTIONS[code]
44
- });
45
-
46
- super(formattedMessage, { code });
47
-
48
- this.name = 'HttpError';
41
+ super(message, options);
49
42
  this.config = options.config || null;
50
- this.code = code;
51
43
  this.request = options.request || null;
52
44
  this.response = options.response || null;
53
45
  this.status = options.response?.status || null;
54
- this.isHttpError = true;
55
46
  }
56
47
 
57
48
  /**
58
49
  * Check if an error is an HttpError
59
50
  * @param {any} error - The error to check
60
- * @returns {boolean} True if the error is an HttpError
51
+ * @returns {boolean}
61
52
  */
62
53
  static isHttpError(error) {
63
54
  return error?.isHttpError === true;
64
55
  }
65
56
 
66
- /**
67
- * Check if this is a timeout error
68
- * @returns {boolean}
69
- */
70
- isTimeout() {
71
- return this.code === 'TIMEOUT';
72
- }
73
-
74
- /**
75
- * Check if this is a network error
76
- * @returns {boolean}
77
- */
78
- isNetworkError() {
79
- return this.code === 'NETWORK';
80
- }
81
-
82
57
  /**
83
58
  * Check if this is an abort/cancellation error
84
59
  * @returns {boolean}
@@ -88,63 +63,6 @@ export class HttpError extends RuntimeError {
88
63
  }
89
64
  }
90
65
 
91
- // ============================================================================
92
- // Interceptor Manager
93
- // ============================================================================
94
-
95
- /**
96
- * Manages request or response interceptors
97
- */
98
- class InterceptorManager {
99
- #handlers = new Map();
100
- #idCounter = 0;
101
-
102
- /**
103
- * Add an interceptor
104
- * @param {Function} fulfilled - Function to run on success
105
- * @param {Function} [rejected] - Function to run on error
106
- * @returns {number} Interceptor ID (for removal)
107
- */
108
- use(fulfilled, rejected) {
109
- const id = this.#idCounter++;
110
- this.#handlers.set(id, { fulfilled, rejected });
111
- return id;
112
- }
113
-
114
- /**
115
- * Remove an interceptor by ID
116
- * @param {number} id - The interceptor ID
117
- */
118
- eject(id) {
119
- this.#handlers.delete(id);
120
- }
121
-
122
- /**
123
- * Remove all interceptors
124
- */
125
- clear() {
126
- this.#handlers.clear();
127
- }
128
-
129
- /**
130
- * Iterate through handlers
131
- * @yields {Object} Handler with fulfilled and rejected functions
132
- */
133
- *[Symbol.iterator]() {
134
- for (const handler of this.#handlers.values()) {
135
- yield handler;
136
- }
137
- }
138
-
139
- /**
140
- * Get the number of interceptors
141
- * @returns {number}
142
- */
143
- get size() {
144
- return this.#handlers.size;
145
- }
146
- }
147
-
148
66
  // ============================================================================
149
67
  // HTTP Client
150
68
  // ============================================================================