@fitzzero/quickdraw-core 0.1.0 → 1.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.
- package/README.md +122 -28
- package/dist/client/index.d.ts +30 -37
- package/dist/client/index.js +103 -58
- package/dist/client/index.js.map +1 -1
- package/dist/client/testing.d.ts +1 -1
- package/dist/client/testing.js +32 -0
- package/dist/client/testing.js.map +1 -1
- package/dist/server/index.d.ts +577 -9
- package/dist/server/index.js +985 -47
- package/dist/server/index.js.map +1 -1
- package/dist/server/testing.d.ts +2 -2
- package/dist/server/testing.js +124 -36
- package/dist/server/testing.js.map +1 -1
- package/dist/shared/index.d.ts +111 -1
- package/dist/shared/index.js.map +1 -1
- package/dist/{types-CUExGF7F.d.ts → types-BADirRXn.d.ts} +50 -8
- package/dist/{types-OhRxDBID.d.ts → types-D7oHWh4O.d.ts} +60 -2
- package/package.json +12 -13
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @quickdraw
|
|
1
|
+
# @fitzzero/quickdraw-core
|
|
2
2
|
|
|
3
3
|
Fast fullstack patterns for real-time applications with Socket.io and TanStack Query.
|
|
4
4
|
|
|
@@ -13,7 +13,7 @@ Fast fullstack patterns for real-time applications with Socket.io and TanStack Q
|
|
|
13
13
|
## Installation
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
pnpm add @quickdraw
|
|
16
|
+
pnpm add @fitzzero/quickdraw-core
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
## Quick Start
|
|
@@ -21,7 +21,7 @@ pnpm add @quickdraw/core
|
|
|
21
21
|
### Server Setup
|
|
22
22
|
|
|
23
23
|
```typescript
|
|
24
|
-
import { createQuickdrawServer, BaseService } from '@quickdraw
|
|
24
|
+
import { createQuickdrawServer, BaseService } from '@fitzzero/quickdraw-core/server';
|
|
25
25
|
import { PrismaClient } from '@prisma/client';
|
|
26
26
|
|
|
27
27
|
const prisma = new PrismaClient();
|
|
@@ -62,7 +62,7 @@ const { io, httpServer } = createQuickdrawServer({
|
|
|
62
62
|
|
|
63
63
|
```tsx
|
|
64
64
|
// app/layout.tsx
|
|
65
|
-
import { QuickdrawProvider } from '@quickdraw
|
|
65
|
+
import { QuickdrawProvider } from '@fitzzero/quickdraw-core/client';
|
|
66
66
|
|
|
67
67
|
export default function RootLayout({ children }) {
|
|
68
68
|
return (
|
|
@@ -76,7 +76,7 @@ export default function RootLayout({ children }) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
// app/chat/page.tsx
|
|
79
|
-
import { useService, useSubscription } from '@quickdraw
|
|
79
|
+
import { useService, useSubscription } from '@fitzzero/quickdraw-core/client';
|
|
80
80
|
|
|
81
81
|
function ChatPage({ chatId }: { chatId: string }) {
|
|
82
82
|
// Subscribe to real-time updates
|
|
@@ -103,7 +103,7 @@ function ChatPage({ chatId }: { chatId: string }) {
|
|
|
103
103
|
### Socket Inputs
|
|
104
104
|
|
|
105
105
|
```tsx
|
|
106
|
-
import { SocketTextField } from '@quickdraw
|
|
106
|
+
import { SocketTextField } from '@fitzzero/quickdraw-core/client';
|
|
107
107
|
|
|
108
108
|
function ChatTitleEditor({ chat, updateChat }) {
|
|
109
109
|
return (
|
|
@@ -123,7 +123,7 @@ function ChatTitleEditor({ chat, updateChat }) {
|
|
|
123
123
|
|
|
124
124
|
```typescript
|
|
125
125
|
// Shared types (both server and client)
|
|
126
|
-
import { ServiceResponse, AccessLevel, ServiceMethodMap } from '@quickdraw
|
|
126
|
+
import { ServiceResponse, AccessLevel, ServiceMethodMap } from '@fitzzero/quickdraw-core';
|
|
127
127
|
|
|
128
128
|
// Server
|
|
129
129
|
import {
|
|
@@ -134,10 +134,10 @@ import {
|
|
|
134
134
|
verifyJWT,
|
|
135
135
|
discordProvider,
|
|
136
136
|
googleProvider,
|
|
137
|
-
} from '@quickdraw
|
|
137
|
+
} from '@fitzzero/quickdraw-core/server';
|
|
138
138
|
|
|
139
139
|
// Server testing
|
|
140
|
-
import { createTestServer, connectAsUser, emitWithAck } from '@quickdraw
|
|
140
|
+
import { createTestServer, connectAsUser, emitWithAck } from '@fitzzero/quickdraw-core/server/testing';
|
|
141
141
|
|
|
142
142
|
// Client
|
|
143
143
|
import {
|
|
@@ -150,10 +150,24 @@ import {
|
|
|
150
150
|
SocketSelect,
|
|
151
151
|
SocketSlider,
|
|
152
152
|
SocketSwitch,
|
|
153
|
-
} from '@quickdraw
|
|
153
|
+
} from '@fitzzero/quickdraw-core/client';
|
|
154
154
|
|
|
155
155
|
// Client testing
|
|
156
|
-
import { createMockSocket, createTestWrapper } from '@quickdraw
|
|
156
|
+
import { createMockSocket, createTestWrapper } from '@fitzzero/quickdraw-core/client/testing';
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Local Development
|
|
160
|
+
|
|
161
|
+
This package is developed alongside [quickdraw-chat](https://github.com/fitzzero/quickdraw-chat), a reference implementation.
|
|
162
|
+
|
|
163
|
+
### Using pnpm link
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
# In quickdraw-chat, the package is linked:
|
|
167
|
+
"@fitzzero/quickdraw-core": "link:../../../quickdraw"
|
|
168
|
+
|
|
169
|
+
# Changes to quickdraw-core are instantly available after rebuild
|
|
170
|
+
pnpm build # or pnpm dev for watch mode
|
|
157
171
|
```
|
|
158
172
|
|
|
159
173
|
## Type Definitions
|
|
@@ -162,7 +176,7 @@ Define your service methods in a shared types file:
|
|
|
162
176
|
|
|
163
177
|
```typescript
|
|
164
178
|
// shared/types.ts
|
|
165
|
-
import type { ServiceMethodMap } from '@quickdraw
|
|
179
|
+
import type { ServiceMethodMap } from '@fitzzero/quickdraw-core';
|
|
166
180
|
|
|
167
181
|
export type ChatServiceMethods = ServiceMethodMap<{
|
|
168
182
|
createChat: {
|
|
@@ -221,38 +235,118 @@ export type ChatServiceMethods = ServiceMethodMap<{
|
|
|
221
235
|
|
|
222
236
|
## Access Control
|
|
223
237
|
|
|
224
|
-
Quickdraw
|
|
238
|
+
Quickdraw provides flexible ACL with two complementary levels:
|
|
225
239
|
|
|
226
240
|
### Service-level ACL
|
|
227
241
|
|
|
228
|
-
Stored in `user.serviceAccess
|
|
229
|
-
|
|
230
|
-
|
|
242
|
+
Blanket permissions across all entries in a service. Stored in `user.serviceAccess`:
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
// User model must satisfy QuickdrawUser interface
|
|
246
|
+
interface QuickdrawUser {
|
|
247
|
+
id: string;
|
|
248
|
+
serviceAccess?: Record<string, AccessLevel> | null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Example: Admin access to all chats
|
|
252
|
+
user.serviceAccess = { chatService: "Admin", userService: "Read" };
|
|
231
253
|
```
|
|
232
254
|
|
|
233
255
|
### Entry-level ACL
|
|
234
256
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
257
|
+
Per-entity permissions. Quickdraw supports two patterns:
|
|
258
|
+
|
|
259
|
+
#### Pattern 1: JSON ACL (Simple)
|
|
260
|
+
|
|
261
|
+
Store ACL directly on the entity. Best for:
|
|
262
|
+
- Simple ownership models (owner + collaborators)
|
|
263
|
+
- When you don't need to query "all entities user X can access" efficiently
|
|
264
|
+
- Minimal schema complexity
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
// Entity must satisfy ACLEntity interface
|
|
268
|
+
interface ACLEntity {
|
|
269
|
+
id: string;
|
|
270
|
+
acl?: ACL | null; // ACL = Array<{ userId: string; level: AccessLevel }>
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Prisma schema
|
|
274
|
+
model Document {
|
|
275
|
+
id String @id @default(cuid())
|
|
276
|
+
acl Json? // Stores [{ userId: "...", level: "Read" }]
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Service - uses default checkEntryACL (no override needed)
|
|
280
|
+
class DocumentService extends BaseService<Document, ...> {
|
|
281
|
+
constructor(prisma: PrismaClient) {
|
|
282
|
+
super({ serviceName: "documentService", hasEntryACL: true });
|
|
283
|
+
this.setDelegate(prisma.document);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
#### Pattern 2: Membership Table (Complex)
|
|
289
|
+
|
|
290
|
+
Separate table for memberships. Best for:
|
|
291
|
+
- Querying "all entities user X can access" efficiently
|
|
292
|
+
- Complex role hierarchies
|
|
293
|
+
- Additional membership metadata (join date, invited by, etc.)
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
// Prisma schema
|
|
297
|
+
model Chat {
|
|
298
|
+
id String @id
|
|
299
|
+
members ChatMember[]
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
model ChatMember {
|
|
303
|
+
chatId String
|
|
304
|
+
userId String
|
|
305
|
+
level String // "Read" | "Moderate" | "Admin"
|
|
306
|
+
|
|
307
|
+
@@unique([chatId, userId])
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Service - override checkEntryACL to use membership table
|
|
311
|
+
class ChatService extends BaseService<Chat, ...> {
|
|
312
|
+
protected override async checkEntryACL(
|
|
313
|
+
userId: string,
|
|
314
|
+
chatId: string,
|
|
315
|
+
requiredLevel: AccessLevel
|
|
316
|
+
): Promise<boolean> {
|
|
317
|
+
const member = await this.prisma.chatMember.findUnique({
|
|
318
|
+
where: { chatId_userId: { chatId, userId } },
|
|
319
|
+
});
|
|
320
|
+
if (!member) return false;
|
|
321
|
+
return this.isLevelSufficient(member.level as AccessLevel, requiredLevel);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
241
324
|
```
|
|
242
325
|
|
|
326
|
+
### Access Check Order
|
|
327
|
+
|
|
328
|
+
When a method is called, `ensureAccessForMethod` checks in this order:
|
|
329
|
+
|
|
330
|
+
1. **Service-level**: `socket.serviceAccess[serviceName] >= requiredLevel` → Allow
|
|
331
|
+
2. **Custom override**: `checkAccess()` returns true → Allow (use for self-access patterns)
|
|
332
|
+
3. **Entry-level**: `checkEntryACL()` returns true → Allow (JSON ACL or membership table)
|
|
333
|
+
4. **Deny** if none of the above
|
|
334
|
+
|
|
243
335
|
### Access Levels
|
|
244
336
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
337
|
+
| Level | Value | Typical Use |
|
|
338
|
+
|-------|-------|-------------|
|
|
339
|
+
| Public | 0 | No authentication required |
|
|
340
|
+
| Read | 1 | View data, subscribe to updates |
|
|
341
|
+
| Moderate | 2 | Edit content, manage members |
|
|
342
|
+
| Admin | 3 | Delete, manage ACL, full control |
|
|
249
343
|
|
|
250
344
|
## Testing
|
|
251
345
|
|
|
252
346
|
### Server Integration Tests
|
|
253
347
|
|
|
254
348
|
```typescript
|
|
255
|
-
import { createTestServer, connectAsUser, emitWithAck } from '@quickdraw
|
|
349
|
+
import { createTestServer, connectAsUser, emitWithAck } from '@fitzzero/quickdraw-core/server/testing';
|
|
256
350
|
|
|
257
351
|
describe('ChatService', () => {
|
|
258
352
|
let server;
|
|
@@ -278,7 +372,7 @@ describe('ChatService', () => {
|
|
|
278
372
|
### Client Component Tests
|
|
279
373
|
|
|
280
374
|
```typescript
|
|
281
|
-
import { createTestWrapper, createMockSocket, mockSuccessEmit } from '@quickdraw
|
|
375
|
+
import { createTestWrapper, createMockSocket, mockSuccessEmit } from '@fitzzero/quickdraw-core/client/testing';
|
|
282
376
|
|
|
283
377
|
test('renders chat', () => {
|
|
284
378
|
const mockSocket = createMockSocket();
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { a as QuickdrawProviderProps, Q as QuickdrawSocketContextValue, U as UseServiceOptions, b as UseServiceResult, c as UseSubscriptionOptions, d as UseSubscriptionResult } from '../types-
|
|
3
|
-
export { C as ClientServiceMethodMap, S as SubscriptionDataMap } from '../types-
|
|
2
|
+
import { a as QuickdrawProviderProps, Q as QuickdrawSocketContextValue, U as UseServiceOptions, b as UseServiceResult, c as UseSubscriptionOptions, d as UseSubscriptionResult } from '../types-BADirRXn.js';
|
|
3
|
+
export { C as ClientServiceMethodMap, S as SubscriptionDataMap } from '../types-BADirRXn.js';
|
|
4
4
|
import 'socket.io-client';
|
|
5
5
|
import '@tanstack/react-query';
|
|
6
6
|
import '../shared/index.js';
|
|
@@ -33,52 +33,44 @@ declare function QuickdrawProvider({ children, serverUrl, queryClient, authToken
|
|
|
33
33
|
/**
|
|
34
34
|
* Hook for invoking service methods via Socket.io with TanStack Query integration.
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
* @typeParam TService - Service name
|
|
38
|
-
* @typeParam TMethod - Method name
|
|
36
|
+
* This hook supports two usage patterns:
|
|
39
37
|
*
|
|
40
|
-
*
|
|
38
|
+
* 1. **Simple usage** - Just pass service/method names with explicit payload/response types:
|
|
41
39
|
* ```tsx
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* createChat
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* // In your component
|
|
49
|
-
* function CreateChatButton() {
|
|
50
|
-
* const createChat = useService<ChatServiceMethods, 'chatService', 'createChat'>(
|
|
51
|
-
* 'chatService',
|
|
52
|
-
* 'createChat',
|
|
53
|
-
* {
|
|
54
|
-
* onSuccess: (data) => {
|
|
55
|
-
* router.push(`/chat/${data.id}`);
|
|
56
|
-
* },
|
|
57
|
-
* }
|
|
58
|
-
* );
|
|
40
|
+
* const createChat = useService<{ title: string }, { id: string }>(
|
|
41
|
+
* 'chatService',
|
|
42
|
+
* 'createChat'
|
|
43
|
+
* );
|
|
44
|
+
* ```
|
|
59
45
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
46
|
+
* 2. **Type-safe usage** - Pass a service methods map for full type inference:
|
|
47
|
+
* ```tsx
|
|
48
|
+
* // In your app, create a typed wrapper:
|
|
49
|
+
* function useTypedService<
|
|
50
|
+
* TService extends keyof ServiceMethodsMap,
|
|
51
|
+
* TMethod extends keyof ServiceMethodsMap[TService] & string,
|
|
52
|
+
* >(serviceName: TService, methodName: TMethod, options?: ...) {
|
|
53
|
+
* type Methods = ServiceMethodsMap[TService];
|
|
54
|
+
* type Payload = Methods[TMethod] extends { payload: infer P } ? P : never;
|
|
55
|
+
* type Response = Methods[TMethod] extends { response: infer R } ? R : never;
|
|
56
|
+
* return useService<Payload, Response>(serviceName, methodName, options);
|
|
68
57
|
* }
|
|
69
58
|
* ```
|
|
59
|
+
*
|
|
60
|
+
* @typeParam TPayload - The payload type for the method
|
|
61
|
+
* @typeParam TResponse - The response type for the method
|
|
70
62
|
*/
|
|
71
|
-
declare function useService<
|
|
72
|
-
payload: unknown;
|
|
73
|
-
response: unknown;
|
|
74
|
-
}>>, TService extends keyof TServiceMethods & string, TMethod extends keyof TServiceMethods[TService] & string>(serviceName: TService, methodName: TMethod, options?: UseServiceOptions<TServiceMethods[TService][TMethod]["response"]>): UseServiceResult<TServiceMethods[TService][TMethod]["payload"], TServiceMethods[TService][TMethod]["response"]>;
|
|
63
|
+
declare function useService<TPayload = unknown, TResponse = unknown>(serviceName: string, methodName: string, options?: UseServiceOptions<TResponse>): UseServiceResult<TPayload, TResponse>;
|
|
75
64
|
/**
|
|
76
65
|
* Simplified hook for basic service method calls.
|
|
77
66
|
* Use this when you don't need the full TanStack Query mutation features.
|
|
78
67
|
*
|
|
79
68
|
* @example
|
|
80
69
|
* ```tsx
|
|
81
|
-
* const { execute, loading, error, data } = useServiceMethod
|
|
70
|
+
* const { execute, loading, error, data } = useServiceMethod<
|
|
71
|
+
* { title: string },
|
|
72
|
+
* { id: string }
|
|
73
|
+
* >('chatService', 'createChat');
|
|
82
74
|
*
|
|
83
75
|
* const handleCreate = async () => {
|
|
84
76
|
* const result = await execute({ title: 'New Chat' });
|
|
@@ -102,8 +94,9 @@ declare function useServiceMethod<TPayload = unknown, TResponse = unknown>(servi
|
|
|
102
94
|
* Features:
|
|
103
95
|
* - Automatic subscription management
|
|
104
96
|
* - TanStack Query cache integration for optimistic updates
|
|
105
|
-
* - Subscription deduplication across components
|
|
97
|
+
* - Subscription deduplication across components (via context-based registry)
|
|
106
98
|
* - Automatic reconnection handling
|
|
99
|
+
* - HMR/Fast Refresh safe (no module-level state)
|
|
107
100
|
*
|
|
108
101
|
* @typeParam TData - The entity type
|
|
109
102
|
*
|
package/dist/client/index.js
CHANGED
|
@@ -1,9 +1,47 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { io } from 'socket.io-client';
|
|
3
|
-
import {
|
|
3
|
+
import { QueryClientProvider, useMutation, useQueryClient, useQuery, QueryClient } from '@tanstack/react-query';
|
|
4
4
|
import { jsx } from 'react/jsx-runtime';
|
|
5
5
|
|
|
6
6
|
// src/client/QuickdrawProvider.tsx
|
|
7
|
+
function createSubscriptionRegistry() {
|
|
8
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
9
|
+
return {
|
|
10
|
+
acquire(key) {
|
|
11
|
+
const existing = subscriptions.get(key);
|
|
12
|
+
if (existing) {
|
|
13
|
+
existing.refCount++;
|
|
14
|
+
return { isNew: false, entry: existing };
|
|
15
|
+
}
|
|
16
|
+
const entry = { refCount: 1 };
|
|
17
|
+
subscriptions.set(key, entry);
|
|
18
|
+
return { isNew: true, entry };
|
|
19
|
+
},
|
|
20
|
+
release(key) {
|
|
21
|
+
const entry = subscriptions.get(key);
|
|
22
|
+
if (!entry) return false;
|
|
23
|
+
entry.refCount--;
|
|
24
|
+
if (entry.refCount <= 0) {
|
|
25
|
+
entry.cleanup?.();
|
|
26
|
+
subscriptions.delete(key);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
},
|
|
31
|
+
setCleanup(key, cleanup) {
|
|
32
|
+
const entry = subscriptions.get(key);
|
|
33
|
+
if (entry) {
|
|
34
|
+
entry.cleanup = cleanup;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
clear() {
|
|
38
|
+
for (const entry of subscriptions.values()) {
|
|
39
|
+
entry.cleanup?.();
|
|
40
|
+
}
|
|
41
|
+
subscriptions.clear();
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
7
45
|
var QuickdrawSocketContext = React.createContext(null);
|
|
8
46
|
function useQuickdrawSocket() {
|
|
9
47
|
const context = React.useContext(QuickdrawSocketContext);
|
|
@@ -14,36 +52,44 @@ function useQuickdrawSocket() {
|
|
|
14
52
|
}
|
|
15
53
|
return context;
|
|
16
54
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
55
|
+
function createQueryClient() {
|
|
56
|
+
return new QueryClient({
|
|
57
|
+
defaultOptions: {
|
|
58
|
+
queries: {
|
|
59
|
+
staleTime: 1e3 * 60 * 5,
|
|
60
|
+
// 5 minutes
|
|
61
|
+
retry: 1
|
|
62
|
+
},
|
|
63
|
+
mutations: {
|
|
64
|
+
retry: 0
|
|
65
|
+
}
|
|
26
66
|
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
29
69
|
function QuickdrawProvider({
|
|
30
70
|
children,
|
|
31
71
|
serverUrl,
|
|
32
|
-
queryClient
|
|
72
|
+
queryClient,
|
|
33
73
|
authToken,
|
|
34
74
|
autoConnect = !!authToken
|
|
35
75
|
}) {
|
|
76
|
+
const [defaultQueryClient] = React.useState(() => createQueryClient());
|
|
77
|
+
const actualQueryClient = queryClient ?? defaultQueryClient;
|
|
36
78
|
const [socket, setSocket] = React.useState(null);
|
|
37
79
|
const [isConnected, setIsConnected] = React.useState(false);
|
|
38
80
|
const [userId, setUserId] = React.useState(null);
|
|
39
81
|
const [serviceAccess, setServiceAccess] = React.useState({});
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
82
|
+
const subscriptionRegistryRef = React.useRef(
|
|
83
|
+
createSubscriptionRegistry()
|
|
84
|
+
);
|
|
85
|
+
const prevAuthTokenRef = React.useRef(authToken);
|
|
86
|
+
const socketRef = React.useRef(null);
|
|
44
87
|
const connect = React.useCallback(
|
|
45
88
|
(token) => {
|
|
46
|
-
|
|
89
|
+
if (socketRef.current) return;
|
|
90
|
+
const authToUse = token;
|
|
91
|
+
subscriptionRegistryRef.current.clear();
|
|
92
|
+
subscriptionRegistryRef.current = createSubscriptionRegistry();
|
|
47
93
|
const newSocket = io(serverUrl, {
|
|
48
94
|
auth: authToUse ? { token: authToUse } : void 0,
|
|
49
95
|
transports: ["websocket", "polling"],
|
|
@@ -54,6 +100,7 @@ function QuickdrawProvider({
|
|
|
54
100
|
});
|
|
55
101
|
newSocket.on("disconnect", () => {
|
|
56
102
|
setIsConnected(false);
|
|
103
|
+
subscriptionRegistryRef.current.clear();
|
|
57
104
|
});
|
|
58
105
|
newSocket.on(
|
|
59
106
|
"auth:info",
|
|
@@ -65,37 +112,46 @@ function QuickdrawProvider({
|
|
|
65
112
|
newSocket.on("connect_error", (error) => {
|
|
66
113
|
console.error("Socket connection error:", error.message);
|
|
67
114
|
});
|
|
115
|
+
socketRef.current = newSocket;
|
|
68
116
|
setSocket(newSocket);
|
|
69
117
|
},
|
|
70
118
|
[serverUrl]
|
|
71
119
|
);
|
|
72
120
|
const disconnect = React.useCallback(() => {
|
|
73
|
-
if (
|
|
74
|
-
|
|
121
|
+
if (socketRef.current) {
|
|
122
|
+
subscriptionRegistryRef.current.clear();
|
|
123
|
+
socketRef.current.disconnect();
|
|
124
|
+
socketRef.current = null;
|
|
75
125
|
setSocket(null);
|
|
76
126
|
setIsConnected(false);
|
|
77
127
|
setUserId(null);
|
|
78
128
|
setServiceAccess({});
|
|
79
129
|
}
|
|
80
|
-
}, [
|
|
130
|
+
}, []);
|
|
81
131
|
React.useEffect(() => {
|
|
82
|
-
if (autoConnect && !
|
|
83
|
-
connect();
|
|
132
|
+
if (autoConnect && !socketRef.current) {
|
|
133
|
+
connect(authToken);
|
|
84
134
|
}
|
|
85
135
|
return () => {
|
|
86
|
-
if (
|
|
87
|
-
|
|
136
|
+
if (socketRef.current) {
|
|
137
|
+
subscriptionRegistryRef.current.clear();
|
|
138
|
+
socketRef.current.disconnect();
|
|
139
|
+
socketRef.current = null;
|
|
88
140
|
}
|
|
89
141
|
};
|
|
90
|
-
}, [autoConnect]);
|
|
142
|
+
}, [autoConnect, authToken]);
|
|
91
143
|
React.useEffect(() => {
|
|
92
|
-
|
|
93
|
-
|
|
144
|
+
const prevToken = prevAuthTokenRef.current;
|
|
145
|
+
prevAuthTokenRef.current = authToken;
|
|
146
|
+
if (authToken !== prevToken) {
|
|
147
|
+
if (socketRef.current) {
|
|
148
|
+
disconnect();
|
|
149
|
+
}
|
|
94
150
|
if (authToken) {
|
|
95
151
|
connect(authToken);
|
|
96
152
|
}
|
|
97
153
|
}
|
|
98
|
-
}, [authToken,
|
|
154
|
+
}, [authToken, disconnect, connect]);
|
|
99
155
|
const contextValue = React.useMemo(
|
|
100
156
|
() => ({
|
|
101
157
|
socket,
|
|
@@ -103,11 +159,12 @@ function QuickdrawProvider({
|
|
|
103
159
|
userId,
|
|
104
160
|
serviceAccess,
|
|
105
161
|
connect,
|
|
106
|
-
disconnect
|
|
162
|
+
disconnect,
|
|
163
|
+
subscriptionRegistry: subscriptionRegistryRef.current
|
|
107
164
|
}),
|
|
108
165
|
[socket, isConnected, userId, serviceAccess, connect, disconnect]
|
|
109
166
|
);
|
|
110
|
-
return /* @__PURE__ */ jsx(QueryClientProvider, { client:
|
|
167
|
+
return /* @__PURE__ */ jsx(QueryClientProvider, { client: actualQueryClient, children: /* @__PURE__ */ jsx(QuickdrawSocketContext.Provider, { value: contextValue, children }) });
|
|
111
168
|
}
|
|
112
169
|
function useService(serviceName, methodName, options) {
|
|
113
170
|
const { socket, isConnected } = useQuickdrawSocket();
|
|
@@ -126,7 +183,7 @@ function useService(serviceName, methodName, options) {
|
|
|
126
183
|
const eventName = `${serviceName}:${methodName}`;
|
|
127
184
|
const timeout = setTimeout(() => {
|
|
128
185
|
reject(new Error("Request timeout"));
|
|
129
|
-
}, 1e4);
|
|
186
|
+
}, options?.timeout ?? 1e4);
|
|
130
187
|
socket.emit(
|
|
131
188
|
eventName,
|
|
132
189
|
payload,
|
|
@@ -190,7 +247,7 @@ function useServiceMethod(serviceName, methodName, options) {
|
|
|
190
247
|
setLoading(false);
|
|
191
248
|
optionsRef.current?.onError?.(errorMsg);
|
|
192
249
|
resolve(null);
|
|
193
|
-
}, 1e4);
|
|
250
|
+
}, options?.timeout ?? 1e4);
|
|
194
251
|
socket.emit(
|
|
195
252
|
eventName,
|
|
196
253
|
payload,
|
|
@@ -211,7 +268,7 @@ function useServiceMethod(serviceName, methodName, options) {
|
|
|
211
268
|
);
|
|
212
269
|
});
|
|
213
270
|
},
|
|
214
|
-
[socket, isConnected, serviceName, methodName]
|
|
271
|
+
[socket, isConnected, serviceName, methodName, options?.timeout]
|
|
215
272
|
);
|
|
216
273
|
return {
|
|
217
274
|
execute,
|
|
@@ -221,9 +278,8 @@ function useServiceMethod(serviceName, methodName, options) {
|
|
|
221
278
|
isReady: !!socket && isConnected
|
|
222
279
|
};
|
|
223
280
|
}
|
|
224
|
-
var activeSubscriptions = /* @__PURE__ */ new Map();
|
|
225
281
|
function useSubscription(serviceName, entryId, options = {}) {
|
|
226
|
-
const { socket, isConnected } = useQuickdrawSocket();
|
|
282
|
+
const { socket, isConnected, subscriptionRegistry } = useQuickdrawSocket();
|
|
227
283
|
const queryClient = useQueryClient();
|
|
228
284
|
const [isSubscribed, setIsSubscribed] = React.useState(false);
|
|
229
285
|
const [error, setError] = React.useState(null);
|
|
@@ -236,7 +292,10 @@ function useSubscription(serviceName, entryId, options = {}) {
|
|
|
236
292
|
// Subscriptions should stay fresh via socket updates
|
|
237
293
|
} = options;
|
|
238
294
|
const subscriptionKey = entryId ? `${serviceName}:${entryId}` : null;
|
|
239
|
-
const queryKey =
|
|
295
|
+
const queryKey = React.useMemo(
|
|
296
|
+
() => [serviceName, "subscription", entryId],
|
|
297
|
+
[serviceName, entryId]
|
|
298
|
+
);
|
|
240
299
|
const onDataRef = React.useRef(onData);
|
|
241
300
|
const onErrorRef = React.useRef(onError);
|
|
242
301
|
React.useEffect(() => {
|
|
@@ -256,16 +315,11 @@ function useSubscription(serviceName, entryId, options = {}) {
|
|
|
256
315
|
if (!socket || !isConnected || !subscriptionKey || !enabled || !entryId) {
|
|
257
316
|
return;
|
|
258
317
|
}
|
|
259
|
-
const
|
|
260
|
-
if (
|
|
261
|
-
existing.refCount++;
|
|
318
|
+
const { isNew } = subscriptionRegistry.acquire(subscriptionKey);
|
|
319
|
+
if (!isNew) {
|
|
262
320
|
setIsSubscribed(true);
|
|
263
321
|
return () => {
|
|
264
|
-
|
|
265
|
-
if (existing.refCount === 0) {
|
|
266
|
-
existing.cleanup?.();
|
|
267
|
-
activeSubscriptions.delete(subscriptionKey);
|
|
268
|
-
}
|
|
322
|
+
subscriptionRegistry.release(subscriptionKey);
|
|
269
323
|
};
|
|
270
324
|
}
|
|
271
325
|
const subscribeEvent = `${serviceName}:subscribe`;
|
|
@@ -307,19 +361,9 @@ function useSubscription(serviceName, entryId, options = {}) {
|
|
|
307
361
|
socket.emit(`${serviceName}:unsubscribe`, { entryId });
|
|
308
362
|
setIsSubscribed(false);
|
|
309
363
|
};
|
|
310
|
-
|
|
311
|
-
refCount: 1,
|
|
312
|
-
cleanup
|
|
313
|
-
});
|
|
364
|
+
subscriptionRegistry.setCleanup(subscriptionKey, cleanup);
|
|
314
365
|
return () => {
|
|
315
|
-
|
|
316
|
-
if (sub) {
|
|
317
|
-
sub.refCount--;
|
|
318
|
-
if (sub.refCount === 0) {
|
|
319
|
-
sub.cleanup?.();
|
|
320
|
-
activeSubscriptions.delete(subscriptionKey);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
366
|
+
subscriptionRegistry.release(subscriptionKey);
|
|
323
367
|
};
|
|
324
368
|
}, [
|
|
325
369
|
socket,
|
|
@@ -330,7 +374,8 @@ function useSubscription(serviceName, entryId, options = {}) {
|
|
|
330
374
|
serviceName,
|
|
331
375
|
requiredLevel,
|
|
332
376
|
queryClient,
|
|
333
|
-
queryKey
|
|
377
|
+
queryKey,
|
|
378
|
+
subscriptionRegistry
|
|
334
379
|
]);
|
|
335
380
|
const subscribe = React.useCallback(() => {
|
|
336
381
|
if (!socket || !isConnected || !entryId) return;
|