@fitzzero/quickdraw-core 0.1.0 → 1.0.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 +81 -42
- 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 +417 -5
- package/dist/server/index.js +643 -31
- package/dist/server/index.js.map +1 -1
- package/dist/server/testing.d.ts +1 -1
- package/dist/server/testing.js +45 -20
- package/dist/server/testing.js.map +1 -1
- package/dist/shared/index.d.ts +63 -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-CDhCEpTU.d.ts} +31 -1
- 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,29 +52,36 @@ 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({});
|
|
82
|
+
const subscriptionRegistryRef = React.useRef(
|
|
83
|
+
createSubscriptionRegistry()
|
|
84
|
+
);
|
|
40
85
|
const authTokenRef = React.useRef(authToken);
|
|
41
86
|
React.useEffect(() => {
|
|
42
87
|
authTokenRef.current = authToken;
|
|
@@ -44,6 +89,8 @@ function QuickdrawProvider({
|
|
|
44
89
|
const connect = React.useCallback(
|
|
45
90
|
(token) => {
|
|
46
91
|
const authToUse = token ?? authTokenRef.current;
|
|
92
|
+
subscriptionRegistryRef.current.clear();
|
|
93
|
+
subscriptionRegistryRef.current = createSubscriptionRegistry();
|
|
47
94
|
const newSocket = io(serverUrl, {
|
|
48
95
|
auth: authToUse ? { token: authToUse } : void 0,
|
|
49
96
|
transports: ["websocket", "polling"],
|
|
@@ -54,6 +101,7 @@ function QuickdrawProvider({
|
|
|
54
101
|
});
|
|
55
102
|
newSocket.on("disconnect", () => {
|
|
56
103
|
setIsConnected(false);
|
|
104
|
+
subscriptionRegistryRef.current.clear();
|
|
57
105
|
});
|
|
58
106
|
newSocket.on(
|
|
59
107
|
"auth:info",
|
|
@@ -71,6 +119,7 @@ function QuickdrawProvider({
|
|
|
71
119
|
);
|
|
72
120
|
const disconnect = React.useCallback(() => {
|
|
73
121
|
if (socket) {
|
|
122
|
+
subscriptionRegistryRef.current.clear();
|
|
74
123
|
socket.disconnect();
|
|
75
124
|
setSocket(null);
|
|
76
125
|
setIsConnected(false);
|
|
@@ -84,6 +133,7 @@ function QuickdrawProvider({
|
|
|
84
133
|
}
|
|
85
134
|
return () => {
|
|
86
135
|
if (socket) {
|
|
136
|
+
subscriptionRegistryRef.current.clear();
|
|
87
137
|
socket.disconnect();
|
|
88
138
|
}
|
|
89
139
|
};
|
|
@@ -103,11 +153,12 @@ function QuickdrawProvider({
|
|
|
103
153
|
userId,
|
|
104
154
|
serviceAccess,
|
|
105
155
|
connect,
|
|
106
|
-
disconnect
|
|
156
|
+
disconnect,
|
|
157
|
+
subscriptionRegistry: subscriptionRegistryRef.current
|
|
107
158
|
}),
|
|
108
159
|
[socket, isConnected, userId, serviceAccess, connect, disconnect]
|
|
109
160
|
);
|
|
110
|
-
return /* @__PURE__ */ jsx(QueryClientProvider, { client:
|
|
161
|
+
return /* @__PURE__ */ jsx(QueryClientProvider, { client: actualQueryClient, children: /* @__PURE__ */ jsx(QuickdrawSocketContext.Provider, { value: contextValue, children }) });
|
|
111
162
|
}
|
|
112
163
|
function useService(serviceName, methodName, options) {
|
|
113
164
|
const { socket, isConnected } = useQuickdrawSocket();
|
|
@@ -126,7 +177,7 @@ function useService(serviceName, methodName, options) {
|
|
|
126
177
|
const eventName = `${serviceName}:${methodName}`;
|
|
127
178
|
const timeout = setTimeout(() => {
|
|
128
179
|
reject(new Error("Request timeout"));
|
|
129
|
-
}, 1e4);
|
|
180
|
+
}, options?.timeout ?? 1e4);
|
|
130
181
|
socket.emit(
|
|
131
182
|
eventName,
|
|
132
183
|
payload,
|
|
@@ -190,7 +241,7 @@ function useServiceMethod(serviceName, methodName, options) {
|
|
|
190
241
|
setLoading(false);
|
|
191
242
|
optionsRef.current?.onError?.(errorMsg);
|
|
192
243
|
resolve(null);
|
|
193
|
-
}, 1e4);
|
|
244
|
+
}, options?.timeout ?? 1e4);
|
|
194
245
|
socket.emit(
|
|
195
246
|
eventName,
|
|
196
247
|
payload,
|
|
@@ -211,7 +262,7 @@ function useServiceMethod(serviceName, methodName, options) {
|
|
|
211
262
|
);
|
|
212
263
|
});
|
|
213
264
|
},
|
|
214
|
-
[socket, isConnected, serviceName, methodName]
|
|
265
|
+
[socket, isConnected, serviceName, methodName, options?.timeout]
|
|
215
266
|
);
|
|
216
267
|
return {
|
|
217
268
|
execute,
|
|
@@ -221,9 +272,8 @@ function useServiceMethod(serviceName, methodName, options) {
|
|
|
221
272
|
isReady: !!socket && isConnected
|
|
222
273
|
};
|
|
223
274
|
}
|
|
224
|
-
var activeSubscriptions = /* @__PURE__ */ new Map();
|
|
225
275
|
function useSubscription(serviceName, entryId, options = {}) {
|
|
226
|
-
const { socket, isConnected } = useQuickdrawSocket();
|
|
276
|
+
const { socket, isConnected, subscriptionRegistry } = useQuickdrawSocket();
|
|
227
277
|
const queryClient = useQueryClient();
|
|
228
278
|
const [isSubscribed, setIsSubscribed] = React.useState(false);
|
|
229
279
|
const [error, setError] = React.useState(null);
|
|
@@ -236,7 +286,10 @@ function useSubscription(serviceName, entryId, options = {}) {
|
|
|
236
286
|
// Subscriptions should stay fresh via socket updates
|
|
237
287
|
} = options;
|
|
238
288
|
const subscriptionKey = entryId ? `${serviceName}:${entryId}` : null;
|
|
239
|
-
const queryKey =
|
|
289
|
+
const queryKey = React.useMemo(
|
|
290
|
+
() => [serviceName, "subscription", entryId],
|
|
291
|
+
[serviceName, entryId]
|
|
292
|
+
);
|
|
240
293
|
const onDataRef = React.useRef(onData);
|
|
241
294
|
const onErrorRef = React.useRef(onError);
|
|
242
295
|
React.useEffect(() => {
|
|
@@ -256,16 +309,11 @@ function useSubscription(serviceName, entryId, options = {}) {
|
|
|
256
309
|
if (!socket || !isConnected || !subscriptionKey || !enabled || !entryId) {
|
|
257
310
|
return;
|
|
258
311
|
}
|
|
259
|
-
const
|
|
260
|
-
if (
|
|
261
|
-
existing.refCount++;
|
|
312
|
+
const { isNew } = subscriptionRegistry.acquire(subscriptionKey);
|
|
313
|
+
if (!isNew) {
|
|
262
314
|
setIsSubscribed(true);
|
|
263
315
|
return () => {
|
|
264
|
-
|
|
265
|
-
if (existing.refCount === 0) {
|
|
266
|
-
existing.cleanup?.();
|
|
267
|
-
activeSubscriptions.delete(subscriptionKey);
|
|
268
|
-
}
|
|
316
|
+
subscriptionRegistry.release(subscriptionKey);
|
|
269
317
|
};
|
|
270
318
|
}
|
|
271
319
|
const subscribeEvent = `${serviceName}:subscribe`;
|
|
@@ -307,19 +355,9 @@ function useSubscription(serviceName, entryId, options = {}) {
|
|
|
307
355
|
socket.emit(`${serviceName}:unsubscribe`, { entryId });
|
|
308
356
|
setIsSubscribed(false);
|
|
309
357
|
};
|
|
310
|
-
|
|
311
|
-
refCount: 1,
|
|
312
|
-
cleanup
|
|
313
|
-
});
|
|
358
|
+
subscriptionRegistry.setCleanup(subscriptionKey, cleanup);
|
|
314
359
|
return () => {
|
|
315
|
-
|
|
316
|
-
if (sub) {
|
|
317
|
-
sub.refCount--;
|
|
318
|
-
if (sub.refCount === 0) {
|
|
319
|
-
sub.cleanup?.();
|
|
320
|
-
activeSubscriptions.delete(subscriptionKey);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
360
|
+
subscriptionRegistry.release(subscriptionKey);
|
|
323
361
|
};
|
|
324
362
|
}, [
|
|
325
363
|
socket,
|
|
@@ -330,7 +368,8 @@ function useSubscription(serviceName, entryId, options = {}) {
|
|
|
330
368
|
serviceName,
|
|
331
369
|
requiredLevel,
|
|
332
370
|
queryClient,
|
|
333
|
-
queryKey
|
|
371
|
+
queryKey,
|
|
372
|
+
subscriptionRegistry
|
|
334
373
|
]);
|
|
335
374
|
const subscribe = React.useCallback(() => {
|
|
336
375
|
if (!socket || !isConnected || !entryId) return;
|