@fitzzero/quickdraw-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,690 @@
1
+ import * as React from 'react';
2
+ import { io } from 'socket.io-client';
3
+ import { QueryClient, QueryClientProvider, useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
4
+ import { jsx } from 'react/jsx-runtime';
5
+
6
+ // src/client/QuickdrawProvider.tsx
7
+ var QuickdrawSocketContext = React.createContext(null);
8
+ function useQuickdrawSocket() {
9
+ const context = React.useContext(QuickdrawSocketContext);
10
+ if (!context) {
11
+ throw new Error(
12
+ "useQuickdrawSocket must be used within a QuickdrawProvider"
13
+ );
14
+ }
15
+ return context;
16
+ }
17
+ var defaultQueryClient = new QueryClient({
18
+ defaultOptions: {
19
+ queries: {
20
+ staleTime: 1e3 * 60 * 5,
21
+ // 5 minutes
22
+ retry: 1
23
+ },
24
+ mutations: {
25
+ retry: 0
26
+ }
27
+ }
28
+ });
29
+ function QuickdrawProvider({
30
+ children,
31
+ serverUrl,
32
+ queryClient = defaultQueryClient,
33
+ authToken,
34
+ autoConnect = !!authToken
35
+ }) {
36
+ const [socket, setSocket] = React.useState(null);
37
+ const [isConnected, setIsConnected] = React.useState(false);
38
+ const [userId, setUserId] = React.useState(null);
39
+ const [serviceAccess, setServiceAccess] = React.useState({});
40
+ const authTokenRef = React.useRef(authToken);
41
+ React.useEffect(() => {
42
+ authTokenRef.current = authToken;
43
+ }, [authToken]);
44
+ const connect = React.useCallback(
45
+ (token) => {
46
+ const authToUse = token ?? authTokenRef.current;
47
+ const newSocket = io(serverUrl, {
48
+ auth: authToUse ? { token: authToUse } : void 0,
49
+ transports: ["websocket", "polling"],
50
+ autoConnect: true
51
+ });
52
+ newSocket.on("connect", () => {
53
+ setIsConnected(true);
54
+ });
55
+ newSocket.on("disconnect", () => {
56
+ setIsConnected(false);
57
+ });
58
+ newSocket.on(
59
+ "auth:info",
60
+ (info) => {
61
+ setUserId(info.userId);
62
+ setServiceAccess(info.serviceAccess);
63
+ }
64
+ );
65
+ newSocket.on("connect_error", (error) => {
66
+ console.error("Socket connection error:", error.message);
67
+ });
68
+ setSocket(newSocket);
69
+ },
70
+ [serverUrl]
71
+ );
72
+ const disconnect = React.useCallback(() => {
73
+ if (socket) {
74
+ socket.disconnect();
75
+ setSocket(null);
76
+ setIsConnected(false);
77
+ setUserId(null);
78
+ setServiceAccess({});
79
+ }
80
+ }, [socket]);
81
+ React.useEffect(() => {
82
+ if (autoConnect && !socket) {
83
+ connect();
84
+ }
85
+ return () => {
86
+ if (socket) {
87
+ socket.disconnect();
88
+ }
89
+ };
90
+ }, [autoConnect]);
91
+ React.useEffect(() => {
92
+ if (socket && authToken !== authTokenRef.current) {
93
+ disconnect();
94
+ if (authToken) {
95
+ connect(authToken);
96
+ }
97
+ }
98
+ }, [authToken, socket, disconnect, connect]);
99
+ const contextValue = React.useMemo(
100
+ () => ({
101
+ socket,
102
+ isConnected,
103
+ userId,
104
+ serviceAccess,
105
+ connect,
106
+ disconnect
107
+ }),
108
+ [socket, isConnected, userId, serviceAccess, connect, disconnect]
109
+ );
110
+ return /* @__PURE__ */ jsx(QueryClientProvider, { client: queryClient, children: /* @__PURE__ */ jsx(QuickdrawSocketContext.Provider, { value: contextValue, children }) });
111
+ }
112
+ function useService(serviceName, methodName, options) {
113
+ const { socket, isConnected } = useQuickdrawSocket();
114
+ const [error, setError] = React.useState(null);
115
+ const optionsRef = React.useRef(options);
116
+ React.useEffect(() => {
117
+ optionsRef.current = options;
118
+ }, [options]);
119
+ const mutation = useMutation({
120
+ mutationKey: [serviceName, methodName],
121
+ mutationFn: async (payload) => {
122
+ if (!socket || !isConnected) {
123
+ throw new Error("Socket not connected");
124
+ }
125
+ return new Promise((resolve, reject) => {
126
+ const eventName = `${serviceName}:${methodName}`;
127
+ const timeout = setTimeout(() => {
128
+ reject(new Error("Request timeout"));
129
+ }, 1e4);
130
+ socket.emit(
131
+ eventName,
132
+ payload,
133
+ (response) => {
134
+ clearTimeout(timeout);
135
+ if (response.success) {
136
+ resolve(response.data);
137
+ } else {
138
+ reject(new Error(response.error));
139
+ }
140
+ }
141
+ );
142
+ });
143
+ },
144
+ onSuccess: (data) => {
145
+ setError(null);
146
+ optionsRef.current?.onSuccess?.(data);
147
+ },
148
+ onError: (err) => {
149
+ const errorMessage = err.message;
150
+ setError(errorMessage);
151
+ optionsRef.current?.onError?.(errorMessage);
152
+ },
153
+ retry: options?.retry ?? false,
154
+ retryDelay: options?.retryDelay
155
+ });
156
+ return {
157
+ mutate: mutation.mutate,
158
+ mutateAsync: mutation.mutateAsync,
159
+ isPending: mutation.isPending,
160
+ isError: mutation.isError,
161
+ error,
162
+ data: mutation.data,
163
+ reset: mutation.reset
164
+ };
165
+ }
166
+ function useServiceMethod(serviceName, methodName, options) {
167
+ const { socket, isConnected } = useQuickdrawSocket();
168
+ const [loading, setLoading] = React.useState(false);
169
+ const [error, setError] = React.useState(null);
170
+ const [data, setData] = React.useState(null);
171
+ const optionsRef = React.useRef(options);
172
+ React.useEffect(() => {
173
+ optionsRef.current = options;
174
+ }, [options]);
175
+ const execute = React.useCallback(
176
+ async (payload) => {
177
+ if (!socket || !isConnected) {
178
+ const errorMsg = "Socket not connected";
179
+ setError(errorMsg);
180
+ optionsRef.current?.onError?.(errorMsg);
181
+ return null;
182
+ }
183
+ setLoading(true);
184
+ setError(null);
185
+ return new Promise((resolve) => {
186
+ const eventName = `${serviceName}:${methodName}`;
187
+ const timeout = setTimeout(() => {
188
+ const errorMsg = "Request timeout";
189
+ setError(errorMsg);
190
+ setLoading(false);
191
+ optionsRef.current?.onError?.(errorMsg);
192
+ resolve(null);
193
+ }, 1e4);
194
+ socket.emit(
195
+ eventName,
196
+ payload,
197
+ (response) => {
198
+ clearTimeout(timeout);
199
+ setLoading(false);
200
+ if (response.success) {
201
+ setError(null);
202
+ setData(response.data);
203
+ optionsRef.current?.onSuccess?.(response.data);
204
+ resolve(response.data);
205
+ } else {
206
+ setError(response.error);
207
+ optionsRef.current?.onError?.(response.error);
208
+ resolve(null);
209
+ }
210
+ }
211
+ );
212
+ });
213
+ },
214
+ [socket, isConnected, serviceName, methodName]
215
+ );
216
+ return {
217
+ execute,
218
+ loading,
219
+ error,
220
+ data,
221
+ isReady: !!socket && isConnected
222
+ };
223
+ }
224
+ var activeSubscriptions = /* @__PURE__ */ new Map();
225
+ function useSubscription(serviceName, entryId, options = {}) {
226
+ const { socket, isConnected } = useQuickdrawSocket();
227
+ const queryClient = useQueryClient();
228
+ const [isSubscribed, setIsSubscribed] = React.useState(false);
229
+ const [error, setError] = React.useState(null);
230
+ const {
231
+ enabled = true,
232
+ onData,
233
+ onError,
234
+ requiredLevel = "Read",
235
+ staleTime = Infinity
236
+ // Subscriptions should stay fresh via socket updates
237
+ } = options;
238
+ const subscriptionKey = entryId ? `${serviceName}:${entryId}` : null;
239
+ const queryKey = [serviceName, "subscription", entryId];
240
+ const onDataRef = React.useRef(onData);
241
+ const onErrorRef = React.useRef(onError);
242
+ React.useEffect(() => {
243
+ onDataRef.current = onData;
244
+ onErrorRef.current = onError;
245
+ }, [onData, onError]);
246
+ const query = useQuery({
247
+ queryKey,
248
+ queryFn: async () => {
249
+ return null;
250
+ },
251
+ enabled: false,
252
+ // We manage data via socket
253
+ staleTime
254
+ });
255
+ React.useEffect(() => {
256
+ if (!socket || !isConnected || !subscriptionKey || !enabled || !entryId) {
257
+ return;
258
+ }
259
+ const existing = activeSubscriptions.get(subscriptionKey);
260
+ if (existing) {
261
+ existing.refCount++;
262
+ setIsSubscribed(true);
263
+ return () => {
264
+ existing.refCount--;
265
+ if (existing.refCount === 0) {
266
+ existing.cleanup?.();
267
+ activeSubscriptions.delete(subscriptionKey);
268
+ }
269
+ };
270
+ }
271
+ const subscribeEvent = `${serviceName}:subscribe`;
272
+ const updateEvent = `${serviceName}:update:${entryId}`;
273
+ const handleUpdate = (updateData) => {
274
+ const asAny = updateData;
275
+ if (asAny.deleted) {
276
+ queryClient.setQueryData(queryKey, null);
277
+ return;
278
+ }
279
+ queryClient.setQueryData(queryKey, (oldData) => {
280
+ if (!oldData) {
281
+ return updateData;
282
+ }
283
+ const merged = { ...oldData, ...updateData };
284
+ onDataRef.current?.(merged);
285
+ return merged;
286
+ });
287
+ };
288
+ socket.on(updateEvent, handleUpdate);
289
+ socket.emit(
290
+ subscribeEvent,
291
+ { entryId, requiredLevel },
292
+ (response) => {
293
+ if (response.success && response.data) {
294
+ queryClient.setQueryData(queryKey, response.data);
295
+ setIsSubscribed(true);
296
+ setError(null);
297
+ onDataRef.current?.(response.data);
298
+ } else if (!response.success) {
299
+ setError(response.error);
300
+ setIsSubscribed(false);
301
+ onErrorRef.current?.(response.error);
302
+ }
303
+ }
304
+ );
305
+ const cleanup = () => {
306
+ socket.off(updateEvent, handleUpdate);
307
+ socket.emit(`${serviceName}:unsubscribe`, { entryId });
308
+ setIsSubscribed(false);
309
+ };
310
+ activeSubscriptions.set(subscriptionKey, {
311
+ refCount: 1,
312
+ cleanup
313
+ });
314
+ return () => {
315
+ const sub = activeSubscriptions.get(subscriptionKey);
316
+ if (sub) {
317
+ sub.refCount--;
318
+ if (sub.refCount === 0) {
319
+ sub.cleanup?.();
320
+ activeSubscriptions.delete(subscriptionKey);
321
+ }
322
+ }
323
+ };
324
+ }, [
325
+ socket,
326
+ isConnected,
327
+ subscriptionKey,
328
+ enabled,
329
+ entryId,
330
+ serviceName,
331
+ requiredLevel,
332
+ queryClient,
333
+ queryKey
334
+ ]);
335
+ const subscribe = React.useCallback(() => {
336
+ if (!socket || !isConnected || !entryId) return;
337
+ socket.emit(
338
+ `${serviceName}:subscribe`,
339
+ { entryId, requiredLevel },
340
+ (response) => {
341
+ if (response.success && response.data) {
342
+ queryClient.setQueryData(queryKey, response.data);
343
+ setIsSubscribed(true);
344
+ setError(null);
345
+ } else if (!response.success) {
346
+ setError(response.error);
347
+ }
348
+ }
349
+ );
350
+ }, [socket, isConnected, entryId, serviceName, requiredLevel, queryClient, queryKey]);
351
+ const unsubscribe = React.useCallback(() => {
352
+ if (!socket || !entryId) return;
353
+ socket.emit(`${serviceName}:unsubscribe`, { entryId });
354
+ setIsSubscribed(false);
355
+ }, [socket, entryId, serviceName]);
356
+ const data = queryClient.getQueryData(queryKey) ?? null;
357
+ return {
358
+ data,
359
+ isLoading: query.isLoading || !data && isSubscribed,
360
+ isError: !!error,
361
+ error,
362
+ isSubscribed,
363
+ subscribe,
364
+ unsubscribe
365
+ };
366
+ }
367
+ function useSocketInput(options) {
368
+ const {
369
+ state,
370
+ property,
371
+ update,
372
+ commitMode,
373
+ debounceMs = 300,
374
+ format = (v) => v,
375
+ parse = (v) => v,
376
+ onSuccess,
377
+ onError
378
+ } = options;
379
+ const serverValue = state ? state[property] : void 0;
380
+ const formattedServerValue = format(serverValue);
381
+ const [localValue, setLocalValue] = React.useState(formattedServerValue);
382
+ const [inFlight, setInFlight] = React.useState(false);
383
+ const [isDirty, setIsDirty] = React.useState(false);
384
+ const debounceTimerRef = React.useRef(null);
385
+ const pendingValueRef = React.useRef(null);
386
+ React.useEffect(() => {
387
+ if (!isDirty && !inFlight) {
388
+ setLocalValue(formattedServerValue);
389
+ }
390
+ }, [formattedServerValue, isDirty, inFlight]);
391
+ const commit = React.useCallback(async () => {
392
+ if (!isDirty) return;
393
+ const valueToCommit = pendingValueRef.current ?? localValue;
394
+ const parsedValue = parse(valueToCommit);
395
+ setInFlight(true);
396
+ try {
397
+ const result = await update({ [property]: parsedValue });
398
+ setIsDirty(false);
399
+ pendingValueRef.current = null;
400
+ onSuccess?.(result);
401
+ } catch (err) {
402
+ onError?.(err instanceof Error ? err.message : "Update failed");
403
+ setLocalValue(formattedServerValue);
404
+ setIsDirty(false);
405
+ } finally {
406
+ setInFlight(false);
407
+ }
408
+ }, [isDirty, localValue, parse, property, update, onSuccess, onError, formattedServerValue]);
409
+ const onLocalChange = React.useCallback(
410
+ (newValue) => {
411
+ setLocalValue(newValue);
412
+ setIsDirty(true);
413
+ pendingValueRef.current = newValue;
414
+ if (commitMode === "change") {
415
+ void (async () => {
416
+ const parsedValue = parse(newValue);
417
+ setInFlight(true);
418
+ try {
419
+ const result = await update({ [property]: parsedValue });
420
+ setIsDirty(false);
421
+ pendingValueRef.current = null;
422
+ onSuccess?.(result);
423
+ } catch (err) {
424
+ onError?.(err instanceof Error ? err.message : "Update failed");
425
+ } finally {
426
+ setInFlight(false);
427
+ }
428
+ })();
429
+ } else if (commitMode === "debounce") {
430
+ if (debounceTimerRef.current) {
431
+ clearTimeout(debounceTimerRef.current);
432
+ }
433
+ debounceTimerRef.current = setTimeout(() => {
434
+ void commit();
435
+ }, debounceMs);
436
+ }
437
+ },
438
+ [commitMode, parse, property, update, onSuccess, onError, debounceMs, commit]
439
+ );
440
+ const onBlur = React.useCallback(() => {
441
+ if (commitMode === "blur" && isDirty) {
442
+ void commit();
443
+ }
444
+ }, [commitMode, isDirty, commit]);
445
+ React.useEffect(() => {
446
+ return () => {
447
+ if (debounceTimerRef.current) {
448
+ clearTimeout(debounceTimerRef.current);
449
+ }
450
+ };
451
+ }, []);
452
+ return {
453
+ value: localValue,
454
+ onLocalChange,
455
+ commit,
456
+ onBlur,
457
+ inFlight,
458
+ isDirty
459
+ };
460
+ }
461
+ function SocketCheckbox(props) {
462
+ const {
463
+ state,
464
+ update,
465
+ property,
466
+ commitMode = "change",
467
+ debounceMs,
468
+ format,
469
+ parse,
470
+ onSuccess,
471
+ onError,
472
+ disabled,
473
+ ...inputProps
474
+ } = props;
475
+ const defaultFormat = React.useCallback((v) => Boolean(v), []);
476
+ const defaultParse = React.useCallback((v) => Boolean(v), []);
477
+ const { value, onLocalChange, inFlight } = useSocketInput({
478
+ state,
479
+ property,
480
+ update,
481
+ commitMode,
482
+ debounceMs,
483
+ format: format ?? defaultFormat,
484
+ parse: parse ?? defaultParse,
485
+ onSuccess,
486
+ onError
487
+ });
488
+ const handleChange = React.useCallback(
489
+ (event) => {
490
+ onLocalChange(event.target.checked);
491
+ },
492
+ [onLocalChange]
493
+ );
494
+ return /* @__PURE__ */ jsx(
495
+ "input",
496
+ {
497
+ ...inputProps,
498
+ type: "checkbox",
499
+ checked: Boolean(value),
500
+ onChange: handleChange,
501
+ disabled: Boolean(disabled) || inFlight
502
+ }
503
+ );
504
+ }
505
+ function SocketTextField(props) {
506
+ const {
507
+ state,
508
+ update,
509
+ property,
510
+ commitMode = "blur",
511
+ debounceMs = 300,
512
+ format,
513
+ parse,
514
+ onSuccess,
515
+ onError,
516
+ disabled,
517
+ ...inputProps
518
+ } = props;
519
+ const defaultFormat = React.useCallback((v) => String(v ?? ""), []);
520
+ const defaultParse = React.useCallback((v) => String(v), []);
521
+ const { value, onLocalChange, onBlur, inFlight } = useSocketInput({
522
+ state,
523
+ property,
524
+ update,
525
+ commitMode,
526
+ debounceMs,
527
+ format: format ?? defaultFormat,
528
+ parse: parse ?? defaultParse,
529
+ onSuccess,
530
+ onError
531
+ });
532
+ const handleChange = React.useCallback(
533
+ (event) => {
534
+ onLocalChange(event.target.value);
535
+ },
536
+ [onLocalChange]
537
+ );
538
+ return /* @__PURE__ */ jsx(
539
+ "input",
540
+ {
541
+ ...inputProps,
542
+ type: "text",
543
+ value: String(value),
544
+ onChange: handleChange,
545
+ onBlur,
546
+ disabled: Boolean(disabled) || inFlight
547
+ }
548
+ );
549
+ }
550
+ function SocketSelect(props) {
551
+ const {
552
+ state,
553
+ update,
554
+ property,
555
+ children,
556
+ commitMode = "change",
557
+ debounceMs,
558
+ format,
559
+ parse,
560
+ onSuccess,
561
+ onError,
562
+ disabled,
563
+ ...selectProps
564
+ } = props;
565
+ const defaultFormat = React.useCallback((v) => String(v ?? ""), []);
566
+ const defaultParse = React.useCallback((v) => String(v), []);
567
+ const { value, onLocalChange, inFlight } = useSocketInput({
568
+ state,
569
+ property,
570
+ update,
571
+ commitMode,
572
+ debounceMs,
573
+ format: format ?? defaultFormat,
574
+ parse: parse ?? defaultParse,
575
+ onSuccess,
576
+ onError
577
+ });
578
+ const handleChange = React.useCallback(
579
+ (event) => {
580
+ onLocalChange(event.target.value);
581
+ },
582
+ [onLocalChange]
583
+ );
584
+ return /* @__PURE__ */ jsx(
585
+ "select",
586
+ {
587
+ ...selectProps,
588
+ value: String(value),
589
+ onChange: handleChange,
590
+ disabled: Boolean(disabled) || inFlight,
591
+ children
592
+ }
593
+ );
594
+ }
595
+ function SocketSlider(props) {
596
+ const {
597
+ state,
598
+ update,
599
+ property,
600
+ commitMode = "debounce",
601
+ debounceMs = 200,
602
+ format,
603
+ parse,
604
+ onSuccess,
605
+ onError,
606
+ disabled,
607
+ ...inputProps
608
+ } = props;
609
+ const defaultFormat = React.useCallback((v) => Number(v ?? 0), []);
610
+ const defaultParse = React.useCallback((v) => Number(v), []);
611
+ const { value, onLocalChange, inFlight } = useSocketInput({
612
+ state,
613
+ property,
614
+ update,
615
+ commitMode,
616
+ debounceMs,
617
+ format: format ?? defaultFormat,
618
+ parse: parse ?? defaultParse,
619
+ onSuccess,
620
+ onError
621
+ });
622
+ const handleChange = React.useCallback(
623
+ (event) => {
624
+ onLocalChange(Number(event.target.value));
625
+ },
626
+ [onLocalChange]
627
+ );
628
+ return /* @__PURE__ */ jsx(
629
+ "input",
630
+ {
631
+ ...inputProps,
632
+ type: "range",
633
+ value: Number(value),
634
+ onChange: handleChange,
635
+ disabled: Boolean(disabled) || inFlight
636
+ }
637
+ );
638
+ }
639
+ function SocketSwitch(props) {
640
+ const {
641
+ state,
642
+ update,
643
+ property,
644
+ commitMode = "change",
645
+ debounceMs,
646
+ format,
647
+ parse,
648
+ onSuccess,
649
+ onError,
650
+ disabled,
651
+ className,
652
+ ...inputProps
653
+ } = props;
654
+ const defaultFormat = React.useCallback((v) => Boolean(v), []);
655
+ const defaultParse = React.useCallback((v) => Boolean(v), []);
656
+ const { value, onLocalChange, inFlight } = useSocketInput({
657
+ state,
658
+ property,
659
+ update,
660
+ commitMode,
661
+ debounceMs,
662
+ format: format ?? defaultFormat,
663
+ parse: parse ?? defaultParse,
664
+ onSuccess,
665
+ onError
666
+ });
667
+ const handleChange = React.useCallback(
668
+ (event) => {
669
+ onLocalChange(event.target.checked);
670
+ },
671
+ [onLocalChange]
672
+ );
673
+ return /* @__PURE__ */ jsx(
674
+ "input",
675
+ {
676
+ ...inputProps,
677
+ type: "checkbox",
678
+ role: "switch",
679
+ "aria-checked": Boolean(value),
680
+ checked: Boolean(value),
681
+ onChange: handleChange,
682
+ disabled: Boolean(disabled) || inFlight,
683
+ className
684
+ }
685
+ );
686
+ }
687
+
688
+ export { QuickdrawProvider, SocketCheckbox, SocketSelect, SocketSlider, SocketSwitch, SocketTextField, useQuickdrawSocket, useService, useServiceMethod, useSocketInput, useSubscription };
689
+ //# sourceMappingURL=index.js.map
690
+ //# sourceMappingURL=index.js.map