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