pulse-js-framework 1.7.11 → 1.7.12

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,1356 @@
1
+ /**
2
+ * Pulse GraphQL Client - Zero-dependency GraphQL client for Pulse Framework
3
+ * @module pulse-js-framework/runtime/graphql
4
+ */
5
+
6
+ import { pulse, computed, batch, effect, onCleanup } from './pulse.js';
7
+ import { createHttp, HttpError } from './http.js';
8
+ import { createWebSocket, WebSocketError } from './websocket.js';
9
+ import { createVersionedAsync } from './async.js';
10
+ import { RuntimeError, createErrorMessage, getDocsUrl } from './errors.js';
11
+
12
+ // ============================================================================
13
+ // Constants
14
+ // ============================================================================
15
+
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
+ /**
28
+ * graphql-ws protocol message types
29
+ * @see https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md
30
+ */
31
+ const MessageType = {
32
+ // Client -> Server
33
+ ConnectionInit: 'connection_init',
34
+ Subscribe: 'subscribe',
35
+ Complete: 'complete',
36
+ Ping: 'ping',
37
+ Pong: 'pong',
38
+
39
+ // Server -> Client
40
+ ConnectionAck: 'connection_ack',
41
+ Next: 'next',
42
+ Error: 'error'
43
+ // Complete is bidirectional
44
+ };
45
+
46
+ // ============================================================================
47
+ // GraphQL Error Class
48
+ // ============================================================================
49
+
50
+ /**
51
+ * Error class for GraphQL operations
52
+ * @extends RuntimeError
53
+ */
54
+ export class GraphQLError extends RuntimeError {
55
+ /**
56
+ * @param {string} message - Error message
57
+ * @param {Object} [options={}] - Error options
58
+ * @param {string} [options.code] - Error code
59
+ * @param {Array} [options.errors] - GraphQL errors array from response
60
+ * @param {*} [options.data] - Partial data from response
61
+ * @param {Object} [options.extensions] - GraphQL extensions
62
+ * @param {Object} [options.response] - Full HTTP response
63
+ * @param {Object} [options.request] - Request configuration
64
+ */
65
+ 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';
77
+ this.errors = options.errors || [];
78
+ this.data = options.data ?? null;
79
+ this.extensions = options.extensions || {};
80
+ this.response = options.response || null;
81
+ this.request = options.request || null;
82
+ }
83
+
84
+ /**
85
+ * Check if error is a GraphQLError
86
+ * @param {*} error - Error to check
87
+ * @returns {boolean}
88
+ */
89
+ static isGraphQLError(error) {
90
+ return error instanceof GraphQLError;
91
+ }
92
+
93
+ /**
94
+ * Check if response has partial data along with errors
95
+ * @returns {boolean}
96
+ */
97
+ hasPartialData() {
98
+ return this.data !== null && this.data !== undefined;
99
+ }
100
+
101
+ /**
102
+ * Check if this is an authentication error
103
+ * @returns {boolean}
104
+ */
105
+ isAuthenticationError() {
106
+ return this.code === 'AUTHENTICATION_ERROR' ||
107
+ this.errors.some(e => e.extensions?.code === 'UNAUTHENTICATED');
108
+ }
109
+
110
+ /**
111
+ * Check if this is an authorization error
112
+ * @returns {boolean}
113
+ */
114
+ isAuthorizationError() {
115
+ return this.code === 'AUTHORIZATION_ERROR' ||
116
+ this.errors.some(e => e.extensions?.code === 'FORBIDDEN');
117
+ }
118
+
119
+ /**
120
+ * Check if this is a validation error
121
+ * @returns {boolean}
122
+ */
123
+ isValidationError() {
124
+ return this.code === 'VALIDATION_ERROR' ||
125
+ this.errors.some(e => e.extensions?.code === 'BAD_USER_INPUT');
126
+ }
127
+
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
+ /**
145
+ * Get the first error message from GraphQL errors
146
+ * @returns {string|null}
147
+ */
148
+ getFirstError() {
149
+ return this.errors[0]?.message || null;
150
+ }
151
+
152
+ /**
153
+ * Get all error messages
154
+ * @returns {string[]}
155
+ */
156
+ getAllErrors() {
157
+ return this.errors.map(e => e.message);
158
+ }
159
+
160
+ /**
161
+ * Convert to JSON
162
+ * @returns {Object}
163
+ */
164
+ toJSON() {
165
+ return {
166
+ name: this.name,
167
+ message: this.message,
168
+ code: this.code,
169
+ errors: this.errors,
170
+ data: this.data,
171
+ extensions: this.extensions
172
+ };
173
+ }
174
+ }
175
+
176
+ // ============================================================================
177
+ // Cache Key Utilities
178
+ // ============================================================================
179
+
180
+ /**
181
+ * Extract operation name from GraphQL query string
182
+ * @param {string} query - GraphQL query string
183
+ * @returns {string|null} Operation name or null
184
+ */
185
+ function extractOperationName(query) {
186
+ const match = query.match(/(?:query|mutation|subscription)\s+(\w+)/);
187
+ return match ? match[1] : null;
188
+ }
189
+
190
+ /**
191
+ * Simple hash function for strings
192
+ * @param {string} str - String to hash
193
+ * @returns {string} Hash string
194
+ */
195
+ function hashString(str) {
196
+ if (!str) return '';
197
+ let hash = 0;
198
+ for (let i = 0; i < str.length; i++) {
199
+ const char = str.charCodeAt(i);
200
+ hash = ((hash << 5) - hash) + char;
201
+ hash = hash & hash; // Convert to 32bit integer
202
+ }
203
+ return Math.abs(hash).toString(36);
204
+ }
205
+
206
+ /**
207
+ * Stable JSON stringify with sorted keys
208
+ * @param {*} obj - Object to stringify
209
+ * @returns {string} Stable JSON string
210
+ */
211
+ function stableStringify(obj) {
212
+ if (obj === null || obj === undefined) return '';
213
+ if (typeof obj !== 'object') return JSON.stringify(obj);
214
+ if (Array.isArray(obj)) {
215
+ return '[' + obj.map(stableStringify).join(',') + ']';
216
+ }
217
+ const keys = Object.keys(obj).sort();
218
+ return '{' + keys.map(k => `"${k}":${stableStringify(obj[k])}`).join(',') + '}';
219
+ }
220
+
221
+ /**
222
+ * Generate a deterministic cache key for a GraphQL operation
223
+ * @param {string} query - GraphQL query string
224
+ * @param {Object} [variables] - Query variables
225
+ * @returns {string} Cache key
226
+ */
227
+ function generateCacheKey(query, variables) {
228
+ const operationName = extractOperationName(query);
229
+ const variablesHash = variables ? hashString(stableStringify(variables)) : '';
230
+ const queryHash = operationName || hashString(query.replace(/\s+/g, ' ').trim());
231
+ return `gql:${queryHash}${variablesHash ? ':' + variablesHash : ''}`;
232
+ }
233
+
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
+ // ============================================================================
289
+ // Subscription Manager
290
+ // ============================================================================
291
+
292
+ /**
293
+ * Manages GraphQL subscriptions over WebSocket
294
+ */
295
+ class SubscriptionManager {
296
+ #ws = null;
297
+ #subscriptions = new Map();
298
+ #nextId = 1;
299
+ #connectionParams;
300
+ #connected = pulse(false);
301
+ #pending = [];
302
+
303
+ /**
304
+ * @param {Object} ws - WebSocket instance from createWebSocket
305
+ * @param {Object|Function} [connectionParams] - Connection parameters
306
+ */
307
+ constructor(ws, connectionParams) {
308
+ this.#ws = ws;
309
+ this.#connectionParams = connectionParams;
310
+
311
+ // Handle incoming messages
312
+ ws.on('message', (msg) => this.#handleMessage(msg));
313
+ ws.on('open', () => this.#handleOpen());
314
+ ws.on('close', () => this.#handleClose());
315
+ ws.on('error', (err) => this.#handleError(err));
316
+ }
317
+
318
+ /**
319
+ * Get connection state
320
+ */
321
+ get connected() {
322
+ return this.#connected;
323
+ }
324
+
325
+ /**
326
+ * Handle WebSocket open
327
+ */
328
+ async #handleOpen() {
329
+ // Send connection_init
330
+ const params = typeof this.#connectionParams === 'function'
331
+ ? await this.#connectionParams()
332
+ : this.#connectionParams;
333
+
334
+ this.#ws.send({
335
+ type: MessageType.ConnectionInit,
336
+ payload: params || {}
337
+ });
338
+ }
339
+
340
+ /**
341
+ * Handle WebSocket close
342
+ */
343
+ #handleClose() {
344
+ this.#connected.set(false);
345
+ // Notify all active subscriptions
346
+ for (const [id, sub] of this.#subscriptions) {
347
+ sub.handlers.onError?.(new GraphQLError('Connection closed', {
348
+ code: 'SUBSCRIPTION_ERROR'
349
+ }));
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Handle WebSocket error
355
+ */
356
+ #handleError(error) {
357
+ for (const [id, sub] of this.#subscriptions) {
358
+ sub.handlers.onError?.(new GraphQLError(error.message || 'WebSocket error', {
359
+ code: 'SUBSCRIPTION_ERROR'
360
+ }));
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Handle incoming WebSocket message
366
+ * @param {Object} message - Parsed message
367
+ */
368
+ #handleMessage(message) {
369
+ const { id, type, payload } = message;
370
+
371
+ switch (type) {
372
+ case MessageType.ConnectionAck:
373
+ this.#connected.set(true);
374
+ // Send any pending subscriptions
375
+ for (const pending of this.#pending) {
376
+ this.#ws.send(pending);
377
+ }
378
+ this.#pending = [];
379
+ break;
380
+
381
+ case MessageType.Next: {
382
+ const sub = this.#subscriptions.get(id);
383
+ if (sub) {
384
+ sub.handlers.onData?.(payload.data);
385
+ }
386
+ break;
387
+ }
388
+
389
+ case MessageType.Error: {
390
+ const sub = this.#subscriptions.get(id);
391
+ if (sub) {
392
+ sub.handlers.onError?.(new GraphQLError('Subscription error', {
393
+ code: 'SUBSCRIPTION_ERROR',
394
+ errors: Array.isArray(payload) ? payload : [payload]
395
+ }));
396
+ }
397
+ break;
398
+ }
399
+
400
+ case MessageType.Complete: {
401
+ const sub = this.#subscriptions.get(id);
402
+ if (sub) {
403
+ sub.handlers.onComplete?.();
404
+ this.#subscriptions.delete(id);
405
+ }
406
+ break;
407
+ }
408
+
409
+ case MessageType.Ping:
410
+ this.#ws.send({ type: MessageType.Pong });
411
+ break;
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Subscribe to a GraphQL subscription
417
+ * @param {string} query - GraphQL subscription query
418
+ * @param {Object} [variables] - Subscription variables
419
+ * @param {Object} handlers - Event handlers
420
+ * @returns {Function} Unsubscribe function
421
+ */
422
+ subscribe(query, variables, handlers) {
423
+ const id = String(this.#nextId++);
424
+
425
+ const message = {
426
+ id,
427
+ type: MessageType.Subscribe,
428
+ payload: {
429
+ query,
430
+ variables,
431
+ operationName: extractOperationName(query)
432
+ }
433
+ };
434
+
435
+ // Store subscription
436
+ this.#subscriptions.set(id, {
437
+ query,
438
+ variables,
439
+ handlers
440
+ });
441
+
442
+ // Send or queue the subscription message
443
+ if (this.#connected.get()) {
444
+ this.#ws.send(message);
445
+ } else {
446
+ this.#pending.push(message);
447
+ // Ensure WebSocket is connecting
448
+ if (this.#ws.state.get() === 'closed') {
449
+ this.#ws.connect();
450
+ }
451
+ }
452
+
453
+ // Return unsubscribe function
454
+ return () => {
455
+ if (this.#subscriptions.has(id)) {
456
+ this.#subscriptions.delete(id);
457
+ if (this.#connected.get()) {
458
+ this.#ws.send({ id, type: MessageType.Complete });
459
+ }
460
+ }
461
+ };
462
+ }
463
+
464
+ /**
465
+ * Get active subscription count
466
+ * @returns {number}
467
+ */
468
+ get activeCount() {
469
+ return this.#subscriptions.size;
470
+ }
471
+
472
+ /**
473
+ * Close all subscriptions
474
+ */
475
+ closeAll() {
476
+ for (const id of this.#subscriptions.keys()) {
477
+ if (this.#connected.get()) {
478
+ this.#ws.send({ id, type: MessageType.Complete });
479
+ }
480
+ }
481
+ this.#subscriptions.clear();
482
+ }
483
+
484
+ /**
485
+ * Dispose the subscription manager
486
+ */
487
+ dispose() {
488
+ this.closeAll();
489
+ this.#ws.dispose();
490
+ }
491
+ }
492
+
493
+ // ============================================================================
494
+ // GraphQL Client
495
+ // ============================================================================
496
+
497
+ /**
498
+ * GraphQL Client class
499
+ */
500
+ class GraphQLClient {
501
+ #http;
502
+ #ws = null;
503
+ #subscriptionManager = null;
504
+ #options;
505
+ #inflightQueries = new Map();
506
+ #cache = new Map();
507
+
508
+ /**
509
+ * Request interceptors
510
+ */
511
+ interceptors = {
512
+ request: new InterceptorManager(),
513
+ response: new InterceptorManager()
514
+ };
515
+
516
+ /**
517
+ * @param {Object} options - Client options
518
+ */
519
+ constructor(options = {}) {
520
+ if (!options.url) {
521
+ throw new GraphQLError('GraphQL client requires a url option', {
522
+ code: 'GRAPHQL_ERROR'
523
+ });
524
+ }
525
+
526
+ this.#options = {
527
+ url: options.url,
528
+ wsUrl: options.wsUrl || this.#deriveWsUrl(options.url),
529
+ headers: options.headers || {},
530
+ timeout: options.timeout ?? 30000,
531
+ credentials: options.credentials || 'same-origin',
532
+ retries: options.retries ?? 0,
533
+ retryDelay: options.retryDelay ?? 1000,
534
+ wsConnectionParams: options.wsConnectionParams,
535
+ wsReconnect: options.wsReconnect ?? true,
536
+ wsMaxRetries: options.wsMaxRetries ?? 5,
537
+ cache: options.cache ?? true,
538
+ cacheTime: options.cacheTime ?? 300000,
539
+ staleTime: options.staleTime ?? 0,
540
+ dedupe: options.dedupe ?? true,
541
+ throwOnError: options.throwOnError ?? true,
542
+ onError: options.onError
543
+ };
544
+
545
+ // Create HTTP client for queries/mutations
546
+ this.#http = createHttp({
547
+ baseURL: '',
548
+ timeout: this.#options.timeout,
549
+ headers: {
550
+ 'Content-Type': 'application/json',
551
+ ...this.#options.headers
552
+ },
553
+ withCredentials: this.#options.credentials === 'include',
554
+ retries: this.#options.retries,
555
+ retryDelay: this.#options.retryDelay
556
+ });
557
+ }
558
+
559
+ /**
560
+ * Derive WebSocket URL from HTTP URL
561
+ * @param {string} url - HTTP URL
562
+ * @returns {string} WebSocket URL
563
+ */
564
+ #deriveWsUrl(url) {
565
+ try {
566
+ const parsed = new URL(url, globalThis.location?.origin || 'http://localhost');
567
+ parsed.protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:';
568
+ return parsed.toString();
569
+ } catch {
570
+ return url.replace(/^http/, 'ws');
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Initialize WebSocket for subscriptions (lazy)
576
+ */
577
+ #initWebSocket() {
578
+ if (this.#ws) return;
579
+
580
+ this.#ws = createWebSocket(this.#options.wsUrl, {
581
+ protocols: ['graphql-transport-ws'],
582
+ reconnect: this.#options.wsReconnect,
583
+ maxRetries: this.#options.wsMaxRetries,
584
+ autoConnect: false,
585
+ autoParseJson: true,
586
+ queueWhileDisconnected: true
587
+ });
588
+
589
+ this.#subscriptionManager = new SubscriptionManager(
590
+ this.#ws,
591
+ this.#options.wsConnectionParams
592
+ );
593
+ }
594
+
595
+ /**
596
+ * Execute a GraphQL operation
597
+ * @param {string} query - GraphQL query/mutation
598
+ * @param {Object} [variables] - Variables
599
+ * @param {Object} [options] - Request options
600
+ * @returns {Promise<Object>} Response data
601
+ */
602
+ async #execute(query, variables, options = {}) {
603
+ const operationName = extractOperationName(query);
604
+
605
+ let config = {
606
+ query,
607
+ variables,
608
+ operationName,
609
+ ...options
610
+ };
611
+
612
+ // Run request interceptors
613
+ for (const interceptor of this.interceptors.request) {
614
+ if (interceptor.fulfilled) {
615
+ try {
616
+ config = await interceptor.fulfilled(config);
617
+ } catch (err) {
618
+ if (interceptor.rejected) {
619
+ config = await interceptor.rejected(err);
620
+ } else {
621
+ throw err;
622
+ }
623
+ }
624
+ }
625
+ }
626
+
627
+ try {
628
+ const response = await this.#http.post(this.#options.url, {
629
+ query: config.query,
630
+ variables: config.variables,
631
+ operationName: config.operationName
632
+ });
633
+
634
+ let result = response.data;
635
+
636
+ // Run response interceptors
637
+ for (const interceptor of this.interceptors.response) {
638
+ if (interceptor.fulfilled) {
639
+ try {
640
+ result = await interceptor.fulfilled(result);
641
+ } catch (err) {
642
+ if (interceptor.rejected) {
643
+ result = await interceptor.rejected(err);
644
+ } else {
645
+ throw err;
646
+ }
647
+ }
648
+ }
649
+ }
650
+
651
+ return this.#processResponse(result, config);
652
+ } catch (error) {
653
+ // Convert HTTP errors to GraphQL errors
654
+ if (HttpError.isHttpError(error)) {
655
+ const graphqlError = new GraphQLError(error.message, {
656
+ code: error.isTimeout() ? 'TIMEOUT' : error.isNetworkError() ? 'NETWORK_ERROR' : 'GRAPHQL_ERROR',
657
+ request: config
658
+ });
659
+ this.#options.onError?.(graphqlError);
660
+ throw graphqlError;
661
+ }
662
+ throw error;
663
+ }
664
+ }
665
+
666
+ /**
667
+ * Process GraphQL response
668
+ * @param {Object} response - GraphQL response
669
+ * @param {Object} config - Request config
670
+ * @returns {*} Response data
671
+ */
672
+ #processResponse(response, config) {
673
+ const { data, errors, extensions } = response;
674
+
675
+ // No errors - return data
676
+ if (!errors || errors.length === 0) {
677
+ return data;
678
+ }
679
+
680
+ // Has errors
681
+ const error = new GraphQLError(errors[0].message, {
682
+ code: this.#mapErrorCode(errors[0]),
683
+ errors,
684
+ data,
685
+ extensions,
686
+ request: config
687
+ });
688
+
689
+ this.#options.onError?.(error);
690
+
691
+ if (this.#options.throwOnError) {
692
+ throw error;
693
+ }
694
+
695
+ // Return data even with errors if throwOnError is false
696
+ return data;
697
+ }
698
+
699
+ /**
700
+ * Map GraphQL error to error code
701
+ * @param {Object} error - GraphQL error
702
+ * @returns {string} Error code
703
+ */
704
+ #mapErrorCode(error) {
705
+ const code = error.extensions?.code;
706
+ switch (code) {
707
+ case 'UNAUTHENTICATED': return 'AUTHENTICATION_ERROR';
708
+ case 'FORBIDDEN': return 'AUTHORIZATION_ERROR';
709
+ case 'BAD_USER_INPUT': return 'VALIDATION_ERROR';
710
+ case 'INTERNAL_SERVER_ERROR': return 'GRAPHQL_ERROR';
711
+ default: return 'GRAPHQL_ERROR';
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Execute a GraphQL query
717
+ * @param {string} query - GraphQL query
718
+ * @param {Object} [variables] - Query variables
719
+ * @param {Object} [options] - Query options
720
+ * @returns {Promise<*>} Query result
721
+ */
722
+ async query(query, variables, options = {}) {
723
+ const cacheKey = options.cacheKey || generateCacheKey(query, variables);
724
+
725
+ // Check for in-flight request (deduplication)
726
+ if (this.#options.dedupe && this.#inflightQueries.has(cacheKey)) {
727
+ return this.#inflightQueries.get(cacheKey);
728
+ }
729
+
730
+ const promise = this.#execute(query, variables, options)
731
+ .finally(() => {
732
+ this.#inflightQueries.delete(cacheKey);
733
+ });
734
+
735
+ if (this.#options.dedupe) {
736
+ this.#inflightQueries.set(cacheKey, promise);
737
+ }
738
+
739
+ return promise;
740
+ }
741
+
742
+ /**
743
+ * Execute a GraphQL mutation
744
+ * @param {string} mutation - GraphQL mutation
745
+ * @param {Object} [variables] - Mutation variables
746
+ * @param {Object} [options] - Mutation options
747
+ * @returns {Promise<*>} Mutation result
748
+ */
749
+ async mutate(mutation, variables, options = {}) {
750
+ return this.#execute(mutation, variables, options);
751
+ }
752
+
753
+ /**
754
+ * Subscribe to a GraphQL subscription
755
+ * @param {string} subscription - GraphQL subscription
756
+ * @param {Object} [variables] - Subscription variables
757
+ * @param {Object} handlers - Event handlers
758
+ * @returns {Function} Unsubscribe function
759
+ */
760
+ subscribe(subscription, variables, handlers) {
761
+ this.#initWebSocket();
762
+ return this.#subscriptionManager.subscribe(subscription, variables, handlers);
763
+ }
764
+
765
+ /**
766
+ * Invalidate a cache entry
767
+ * @param {string} cacheKey - Cache key to invalidate
768
+ */
769
+ invalidate(cacheKey) {
770
+ this.#cache.delete(cacheKey);
771
+ }
772
+
773
+ /**
774
+ * Invalidate all cache entries
775
+ */
776
+ invalidateAll() {
777
+ this.#cache.clear();
778
+ }
779
+
780
+ /**
781
+ * Get cache statistics
782
+ * @returns {Object} Cache stats
783
+ */
784
+ getCacheStats() {
785
+ return {
786
+ size: this.#cache.size,
787
+ keys: Array.from(this.#cache.keys())
788
+ };
789
+ }
790
+
791
+ /**
792
+ * Get active subscriptions count
793
+ * @returns {number}
794
+ */
795
+ getActiveSubscriptions() {
796
+ return this.#subscriptionManager?.activeCount || 0;
797
+ }
798
+
799
+ /**
800
+ * Close all subscriptions
801
+ */
802
+ closeAllSubscriptions() {
803
+ this.#subscriptionManager?.closeAll();
804
+ }
805
+
806
+ /**
807
+ * Get WebSocket connection state
808
+ * @returns {Pulse<string>}
809
+ */
810
+ get wsState() {
811
+ this.#initWebSocket();
812
+ return this.#ws.state;
813
+ }
814
+
815
+ /**
816
+ * Get WebSocket connected state
817
+ * @returns {Pulse<boolean>}
818
+ */
819
+ get wsConnected() {
820
+ this.#initWebSocket();
821
+ return this.#subscriptionManager.connected;
822
+ }
823
+
824
+ /**
825
+ * Create a child client with merged configuration
826
+ * @param {Object} options - Override options
827
+ * @returns {GraphQLClient} New client instance
828
+ */
829
+ create(options = {}) {
830
+ return new GraphQLClient({
831
+ ...this.#options,
832
+ headers: { ...this.#options.headers, ...options.headers },
833
+ ...options
834
+ });
835
+ }
836
+
837
+ /**
838
+ * Dispose the client
839
+ */
840
+ dispose() {
841
+ this.#subscriptionManager?.dispose();
842
+ this.#inflightQueries.clear();
843
+ this.#cache.clear();
844
+ }
845
+ }
846
+
847
+ // ============================================================================
848
+ // Factory Function
849
+ // ============================================================================
850
+
851
+ /**
852
+ * Create a GraphQL client instance
853
+ * @param {Object} options - Client options
854
+ * @param {string} options.url - GraphQL endpoint URL
855
+ * @param {string} [options.wsUrl] - WebSocket URL for subscriptions
856
+ * @param {Object} [options.headers] - Default headers
857
+ * @param {number} [options.timeout=30000] - Request timeout in ms
858
+ * @param {string} [options.credentials='same-origin'] - Fetch credentials
859
+ * @param {number} [options.retries=0] - Retry attempts
860
+ * @param {number} [options.retryDelay=1000] - Delay between retries
861
+ * @param {Object|Function} [options.wsConnectionParams] - WebSocket connection params
862
+ * @param {boolean} [options.wsReconnect=true] - Auto-reconnect WebSocket
863
+ * @param {number} [options.wsMaxRetries=5] - Max WebSocket reconnection attempts
864
+ * @param {boolean} [options.cache=true] - Enable query caching
865
+ * @param {number} [options.cacheTime=300000] - Cache TTL in ms
866
+ * @param {number} [options.staleTime=0] - Stale threshold in ms
867
+ * @param {boolean} [options.dedupe=true] - Deduplicate identical in-flight queries
868
+ * @param {boolean} [options.throwOnError=true] - Throw on GraphQL errors
869
+ * @param {Function} [options.onError] - Global error handler
870
+ * @returns {GraphQLClient} GraphQL client instance
871
+ */
872
+ export function createGraphQLClient(options = {}) {
873
+ return new GraphQLClient(options);
874
+ }
875
+
876
+ // ============================================================================
877
+ // Default Client
878
+ // ============================================================================
879
+
880
+ let defaultClient = null;
881
+
882
+ /**
883
+ * Set the default GraphQL client
884
+ * @param {GraphQLClient} client - Client instance
885
+ */
886
+ export function setDefaultClient(client) {
887
+ defaultClient = client;
888
+ }
889
+
890
+ /**
891
+ * Get the default GraphQL client
892
+ * @returns {GraphQLClient|null}
893
+ */
894
+ export function getDefaultClient() {
895
+ return defaultClient;
896
+ }
897
+
898
+ /**
899
+ * Get client from options or default
900
+ * @param {Object} options - Options with optional client
901
+ * @returns {GraphQLClient} Client instance
902
+ */
903
+ function getClient(options) {
904
+ const client = options.client || defaultClient;
905
+ if (!client) {
906
+ throw new GraphQLError(
907
+ 'No GraphQL client provided. Either pass a client option or set a default client with setDefaultClient().',
908
+ { code: 'GRAPHQL_ERROR' }
909
+ );
910
+ }
911
+ return client;
912
+ }
913
+
914
+ // ============================================================================
915
+ // useQuery Hook
916
+ // ============================================================================
917
+
918
+ /**
919
+ * Execute a GraphQL query with caching and reactivity
920
+ * @param {string} query - GraphQL query string
921
+ * @param {Object|Function} [variables] - Variables or function returning variables
922
+ * @param {Object} [options={}] - Query options
923
+ * @param {GraphQLClient} [options.client] - GraphQL client instance
924
+ * @param {boolean|Pulse<boolean>} [options.enabled=true] - Enable/disable query
925
+ * @param {boolean} [options.immediate=true] - Execute immediately
926
+ * @param {string|Function} [options.cacheKey] - Custom cache key
927
+ * @param {number} [options.cacheTime] - Cache TTL override
928
+ * @param {number} [options.staleTime] - Stale threshold override
929
+ * @param {boolean} [options.refetchOnFocus=false] - Refetch when window gains focus
930
+ * @param {boolean} [options.refetchOnReconnect=false] - Refetch when network reconnects
931
+ * @param {number} [options.refetchInterval] - Polling interval in ms
932
+ * @param {number} [options.retry] - Retry attempts
933
+ * @param {number} [options.retryDelay] - Delay between retries
934
+ * @param {Function} [options.onSuccess] - Success callback
935
+ * @param {Function} [options.onError] - Error callback
936
+ * @param {Function} [options.select] - Transform/select data
937
+ * @param {*} [options.placeholderData] - Placeholder while loading
938
+ * @param {boolean} [options.keepPreviousData=false] - Keep previous data during refetch
939
+ * @returns {Object} Query result with reactive state
940
+ */
941
+ export function useQuery(query, variables, options = {}) {
942
+ const client = getClient(options);
943
+
944
+ // Resolve variables (can be function for reactive variables)
945
+ const resolveVariables = () => {
946
+ if (typeof variables === 'function') {
947
+ return variables();
948
+ }
949
+ return variables;
950
+ };
951
+
952
+ // Generate cache key
953
+ const getCacheKey = () => {
954
+ if (typeof options.cacheKey === 'function') {
955
+ return options.cacheKey();
956
+ }
957
+ if (options.cacheKey) {
958
+ return options.cacheKey;
959
+ }
960
+ return generateCacheKey(query, resolveVariables());
961
+ };
962
+
963
+ // Check if enabled
964
+ const isEnabled = () => {
965
+ if (typeof options.enabled === 'object' && options.enabled?.get) {
966
+ return options.enabled.get();
967
+ }
968
+ return options.enabled !== false;
969
+ };
970
+
971
+ // Check if should execute immediately
972
+ const shouldExecuteImmediately = options.immediate !== false && isEnabled();
973
+
974
+ // State
975
+ const data = pulse(options.placeholderData ?? null);
976
+ const error = pulse(null);
977
+ const loading = pulse(shouldExecuteImmediately);
978
+ const fetching = pulse(false);
979
+ const isStale = pulse(false);
980
+
981
+ const versionController = createVersionedAsync();
982
+
983
+ // Execute query
984
+ async function executeQuery() {
985
+ if (!isEnabled()) return null;
986
+
987
+ const ctx = versionController.begin();
988
+
989
+ batch(() => {
990
+ fetching.set(true);
991
+ if (data.get() === null) {
992
+ loading.set(true);
993
+ }
994
+ error.set(null);
995
+ });
996
+
997
+ try {
998
+ const result = await client.query(query, resolveVariables(), {
999
+ cacheKey: getCacheKey()
1000
+ });
1001
+
1002
+ const selectedData = options.select ? options.select(result) : result;
1003
+
1004
+ ctx.ifCurrent(() => {
1005
+ batch(() => {
1006
+ data.set(selectedData);
1007
+ loading.set(false);
1008
+ fetching.set(false);
1009
+ isStale.set(false);
1010
+ });
1011
+ options.onSuccess?.(selectedData);
1012
+ });
1013
+
1014
+ return selectedData;
1015
+ } catch (err) {
1016
+ const graphqlError = GraphQLError.isGraphQLError(err) ? err : new GraphQLError(err.message, {
1017
+ code: 'GRAPHQL_ERROR'
1018
+ });
1019
+
1020
+ ctx.ifCurrent(() => {
1021
+ batch(() => {
1022
+ error.set(graphqlError);
1023
+ loading.set(false);
1024
+ fetching.set(false);
1025
+ });
1026
+ options.onError?.(graphqlError);
1027
+ });
1028
+
1029
+ return null;
1030
+ }
1031
+ }
1032
+
1033
+ // Initial fetch if immediate
1034
+ if (shouldExecuteImmediately) {
1035
+ executeQuery();
1036
+ }
1037
+
1038
+ // Setup auto-refresh interval
1039
+ if (options.refetchInterval && options.refetchInterval > 0) {
1040
+ const intervalId = setInterval(() => {
1041
+ if (!loading.get() && !fetching.get() && isEnabled()) {
1042
+ executeQuery();
1043
+ }
1044
+ }, options.refetchInterval);
1045
+
1046
+ onCleanup(() => clearInterval(intervalId));
1047
+ }
1048
+
1049
+ // 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));
1056
+ }
1057
+
1058
+ // 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));
1065
+ }
1066
+
1067
+ return {
1068
+ data,
1069
+ error,
1070
+ loading,
1071
+ fetching,
1072
+ status: computed(() => {
1073
+ if (loading.get()) return 'loading';
1074
+ if (error.get()) return 'error';
1075
+ if (data.get() !== null) return 'success';
1076
+ return 'idle';
1077
+ }),
1078
+ isStale,
1079
+ refetch: executeQuery,
1080
+ invalidate: () => {
1081
+ isStale.set(true);
1082
+ client.invalidate(getCacheKey());
1083
+ },
1084
+ reset: () => {
1085
+ batch(() => {
1086
+ data.set(null);
1087
+ error.set(null);
1088
+ loading.set(false);
1089
+ fetching.set(false);
1090
+ isStale.set(false);
1091
+ });
1092
+ }
1093
+ };
1094
+ }
1095
+
1096
+ // ============================================================================
1097
+ // useMutation Hook
1098
+ // ============================================================================
1099
+
1100
+ /**
1101
+ * Execute GraphQL mutations
1102
+ * @param {string} mutation - GraphQL mutation string
1103
+ * @param {Object} [options={}] - Mutation options
1104
+ * @param {GraphQLClient} [options.client] - GraphQL client instance
1105
+ * @param {Function} [options.onSuccess] - Success callback
1106
+ * @param {Function} [options.onError] - Error callback
1107
+ * @param {Function} [options.onSettled] - Called after success or error
1108
+ * @param {number} [options.retry] - Retry attempts
1109
+ * @param {number} [options.retryDelay] - Delay between retries
1110
+ * @param {Function} [options.onMutate] - Called before mutation (for optimistic updates)
1111
+ * @param {string[]} [options.invalidateQueries] - Cache keys to invalidate on success
1112
+ * @returns {Object} Mutation result with execute function
1113
+ */
1114
+ export function useMutation(mutation, options = {}) {
1115
+ const client = getClient(options);
1116
+
1117
+ const data = pulse(null);
1118
+ const error = pulse(null);
1119
+ const loading = pulse(false);
1120
+ const status = pulse('idle');
1121
+
1122
+ const versionController = createVersionedAsync();
1123
+
1124
+ /**
1125
+ * Execute the mutation
1126
+ * @param {Object} [variables] - Mutation variables
1127
+ * @returns {Promise<*>} Mutation result
1128
+ */
1129
+ async function mutate(variables) {
1130
+ const ctx = versionController.begin();
1131
+ let rollbackContext;
1132
+
1133
+ batch(() => {
1134
+ loading.set(true);
1135
+ error.set(null);
1136
+ status.set('loading');
1137
+ });
1138
+
1139
+ try {
1140
+ // Call onMutate for optimistic updates
1141
+ if (options.onMutate) {
1142
+ rollbackContext = await options.onMutate(variables);
1143
+ }
1144
+
1145
+ const result = await client.mutate(mutation, variables);
1146
+
1147
+ ctx.ifCurrent(() => {
1148
+ batch(() => {
1149
+ data.set(result);
1150
+ loading.set(false);
1151
+ status.set('success');
1152
+ });
1153
+
1154
+ options.onSuccess?.(result, variables);
1155
+ options.onSettled?.(result, null, variables);
1156
+
1157
+ // Invalidate queries
1158
+ if (options.invalidateQueries) {
1159
+ for (const key of options.invalidateQueries) {
1160
+ client.invalidate(key);
1161
+ }
1162
+ }
1163
+ });
1164
+
1165
+ return result;
1166
+ } catch (err) {
1167
+ const graphqlError = GraphQLError.isGraphQLError(err) ? err : new GraphQLError(err.message, {
1168
+ code: 'GRAPHQL_ERROR'
1169
+ });
1170
+
1171
+ ctx.ifCurrent(() => {
1172
+ batch(() => {
1173
+ error.set(graphqlError);
1174
+ loading.set(false);
1175
+ status.set('error');
1176
+ });
1177
+
1178
+ options.onError?.(graphqlError, variables, rollbackContext);
1179
+ options.onSettled?.(null, graphqlError, variables);
1180
+ });
1181
+
1182
+ throw graphqlError;
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * Reset mutation state
1188
+ */
1189
+ function reset() {
1190
+ batch(() => {
1191
+ data.set(null);
1192
+ error.set(null);
1193
+ loading.set(false);
1194
+ status.set('idle');
1195
+ });
1196
+ }
1197
+
1198
+ return {
1199
+ data,
1200
+ error,
1201
+ loading,
1202
+ status,
1203
+ mutate,
1204
+ mutateAsync: mutate,
1205
+ reset
1206
+ };
1207
+ }
1208
+
1209
+ // ============================================================================
1210
+ // useSubscription Hook
1211
+ // ============================================================================
1212
+
1213
+ /**
1214
+ * Subscribe to GraphQL subscriptions over WebSocket
1215
+ * @param {string} subscription - GraphQL subscription string
1216
+ * @param {Object|Function} [variables] - Variables or function returning variables
1217
+ * @param {Object} [options={}] - Subscription options
1218
+ * @param {GraphQLClient} [options.client] - GraphQL client instance
1219
+ * @param {boolean|Pulse<boolean>} [options.enabled=true] - Enable/disable subscription
1220
+ * @param {Function} [options.onData] - Called on each message
1221
+ * @param {Function} [options.onError] - Error callback
1222
+ * @param {Function} [options.onComplete] - Called when subscription ends
1223
+ * @param {boolean} [options.shouldResubscribe=true] - Resubscribe on error
1224
+ * @returns {Object} Subscription result with reactive state
1225
+ */
1226
+ export function useSubscription(subscription, variables, options = {}) {
1227
+ const client = getClient(options);
1228
+
1229
+ const data = pulse(null);
1230
+ const error = pulse(null);
1231
+ const status = pulse('connecting');
1232
+
1233
+ let unsubscribeFn = null;
1234
+
1235
+ // Resolve variables
1236
+ const resolveVariables = () => {
1237
+ if (typeof variables === 'function') {
1238
+ return variables();
1239
+ }
1240
+ return variables;
1241
+ };
1242
+
1243
+ // Check if enabled
1244
+ const isEnabled = () => {
1245
+ if (typeof options.enabled === 'object' && options.enabled?.get) {
1246
+ return options.enabled.get();
1247
+ }
1248
+ return options.enabled !== false;
1249
+ };
1250
+
1251
+ /**
1252
+ * Start subscription
1253
+ */
1254
+ function subscribe() {
1255
+ if (!isEnabled() || unsubscribeFn) return;
1256
+
1257
+ status.set('connecting');
1258
+
1259
+ unsubscribeFn = client.subscribe(subscription, resolveVariables(), {
1260
+ onData: (payload) => {
1261
+ status.set('connected');
1262
+ data.set(payload);
1263
+ options.onData?.(payload);
1264
+ },
1265
+ onError: (err) => {
1266
+ error.set(err);
1267
+ status.set('error');
1268
+ options.onError?.(err);
1269
+
1270
+ // Resubscribe on error if enabled
1271
+ if (options.shouldResubscribe !== false) {
1272
+ unsubscribeFn = null;
1273
+ setTimeout(() => subscribe(), 1000);
1274
+ }
1275
+ },
1276
+ onComplete: () => {
1277
+ status.set('closed');
1278
+ options.onComplete?.();
1279
+ unsubscribeFn = null;
1280
+ }
1281
+ });
1282
+ }
1283
+
1284
+ /**
1285
+ * Unsubscribe
1286
+ */
1287
+ function unsubscribe() {
1288
+ if (unsubscribeFn) {
1289
+ unsubscribeFn();
1290
+ unsubscribeFn = null;
1291
+ status.set('closed');
1292
+ }
1293
+ }
1294
+
1295
+ /**
1296
+ * Resubscribe (unsubscribe then subscribe)
1297
+ */
1298
+ function resubscribe() {
1299
+ unsubscribe();
1300
+ subscribe();
1301
+ }
1302
+
1303
+ // Start subscription if enabled
1304
+ if (isEnabled()) {
1305
+ subscribe();
1306
+ }
1307
+
1308
+ // Watch enabled state for reactive enabling/disabling
1309
+ if (typeof options.enabled === 'object' && options.enabled?.get) {
1310
+ effect(() => {
1311
+ if (options.enabled.get()) {
1312
+ subscribe();
1313
+ } else {
1314
+ unsubscribe();
1315
+ }
1316
+ });
1317
+ }
1318
+
1319
+ // Cleanup on dispose
1320
+ onCleanup(() => {
1321
+ unsubscribe();
1322
+ });
1323
+
1324
+ return {
1325
+ data,
1326
+ error,
1327
+ status,
1328
+ unsubscribe,
1329
+ resubscribe
1330
+ };
1331
+ }
1332
+
1333
+ // ============================================================================
1334
+ // Exports
1335
+ // ============================================================================
1336
+
1337
+ export {
1338
+ GraphQLClient,
1339
+ InterceptorManager,
1340
+ SubscriptionManager,
1341
+ generateCacheKey,
1342
+ extractOperationName
1343
+ };
1344
+
1345
+ export default {
1346
+ createGraphQLClient,
1347
+ GraphQLClient,
1348
+ GraphQLError,
1349
+ useQuery,
1350
+ useMutation,
1351
+ useSubscription,
1352
+ setDefaultClient,
1353
+ getDefaultClient,
1354
+ generateCacheKey,
1355
+ extractOperationName
1356
+ };