@fitzzero/quickdraw-core 3.7.0 → 3.8.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 +105 -0
- package/dist/client/index.d.ts +34 -4
- package/dist/client/index.js +25 -4
- package/dist/client/index.js.map +1 -1
- package/dist/client/testing.d.ts +1 -1
- package/dist/server/index.d.ts +103 -4
- package/dist/server/index.js +194 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/testing.d.ts +1 -1
- package/dist/server/testing.js +82 -0
- package/dist/server/testing.js.map +1 -1
- package/dist/shared/index.d.ts +77 -1
- package/dist/shared/index.js +5 -1
- package/dist/shared/index.js.map +1 -1
- package/dist/{types-DniMxkQs.d.ts → types-ClgSr90l.d.ts} +5 -1
- package/dist/{types-B1z5aXdR.d.ts → types-DUHPIy_5.d.ts} +20 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -180,6 +180,111 @@ function TaskList({ projectId }: { projectId: string }) {
|
|
|
180
180
|
|
|
181
181
|
Rapid-fire events within 100ms are debounced into a single refetch.
|
|
182
182
|
|
|
183
|
+
### Channels (High-Frequency Traffic)
|
|
184
|
+
|
|
185
|
+
Methods are request/response: ack'd, ACL-checked against the database, and
|
|
186
|
+
counted by the global rate limiter. **Channels** are their fire-and-forget
|
|
187
|
+
counterpart for traffic where per-message overhead matters and losing a
|
|
188
|
+
message is fine — game input, cursor positions, typing indicators, telemetry.
|
|
189
|
+
|
|
190
|
+
Channel messages have no ack and no response. Each message is validated
|
|
191
|
+
(zod schema required), access-checked entirely in memory (zero DB reads on
|
|
192
|
+
the hot path), and governed by a per-socket, per-channel token bucket instead
|
|
193
|
+
of the global limiter. Excess messages are silently dropped; sustained extreme
|
|
194
|
+
flooding disconnects the socket.
|
|
195
|
+
|
|
196
|
+
**Server** — define channels next to methods; broadcast tick data back with
|
|
197
|
+
`emitToRoomVolatile` (backpressured clients drop frames instead of queueing):
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
type GameServiceChannels = ServiceChannelMap<{
|
|
201
|
+
input: { seq: number; dx: number; dy: number; boost: boolean };
|
|
202
|
+
}>;
|
|
203
|
+
|
|
204
|
+
class GameService extends BaseService<
|
|
205
|
+
GameWorld,
|
|
206
|
+
Prisma.GameWorldCreateInput,
|
|
207
|
+
Prisma.GameWorldUpdateInput,
|
|
208
|
+
GameServiceMethods,
|
|
209
|
+
GameServiceChannels // 5th type param
|
|
210
|
+
> {
|
|
211
|
+
constructor(prisma: PrismaClient) {
|
|
212
|
+
super({ serviceName: "gameService" });
|
|
213
|
+
this.setDelegate(prisma.gameWorld);
|
|
214
|
+
|
|
215
|
+
this.defineChannel(
|
|
216
|
+
"input",
|
|
217
|
+
"Read",
|
|
218
|
+
(payload, ctx) => this.sim.applyInput(ctx.userId, payload),
|
|
219
|
+
{
|
|
220
|
+
schema: gameInputSchema,
|
|
221
|
+
ratePerSecond: 30, // default 30
|
|
222
|
+
burst: 60, // default 2x rate
|
|
223
|
+
requireRoom: () => this.getRoomName(WORLD_ID), // entry-level gate
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// In a 20Hz tick loop:
|
|
229
|
+
broadcastSnapshot(snapshot: WorldSnapshot): void {
|
|
230
|
+
this.emitToRoomVolatile(this.getRoomName(WORLD_ID), "game:snapshot", snapshot);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Exempt channel traffic from the global rate limiter (channels self-limit):
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
import { CHANNEL_EVENT_PREFIX } from "@fitzzero/quickdraw-core";
|
|
239
|
+
|
|
240
|
+
const rateLimiter = createRateLimiter({
|
|
241
|
+
maxRequests: 100,
|
|
242
|
+
excludePrefixes: [CHANNEL_EVENT_PREFIX],
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Client** — send with `useChannelSend`; receive broadcasts with the existing
|
|
247
|
+
`useRoomEvents` (volatile room emits arrive as ordinary events):
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
import { useSubscription, useRoomEvents, useChannelSend } from "@fitzzero/quickdraw-core/client";
|
|
251
|
+
|
|
252
|
+
function GameView({ worldId }: { worldId: string }) {
|
|
253
|
+
useSubscription("gameService", worldId); // room membership gates the channel
|
|
254
|
+
const { send, isReady } = useChannelSend<GameInput>("gameService", "input");
|
|
255
|
+
|
|
256
|
+
useRoomEvents({
|
|
257
|
+
"game:snapshot": (snap: WorldSnapshot) => applySnapshot(snap),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// e.g. called from a fixed-timestep loop
|
|
261
|
+
const onTick = (input: GameInput) => send(input);
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**Access model** (all synchronous, in-memory):
|
|
266
|
+
|
|
267
|
+
| Check | Behavior |
|
|
268
|
+
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
269
|
+
| Authentication | Always required — anonymous messages dropped, even at `"Public"` access |
|
|
270
|
+
| `"Public"` / `"Read"` | Any authenticated user passes the service gate |
|
|
271
|
+
| `"Moderate"` / `"Admin"` | Requires that level in the socket's `serviceAccess` |
|
|
272
|
+
| `requireRoom` | Socket must already be in the resolved room — membership was ACL-checked at subscribe time, so this inherits entry ACL semantics without a DB read per message |
|
|
273
|
+
|
|
274
|
+
Channels route as the Socket.io event `channel:<serviceName>:<channelName>`
|
|
275
|
+
(helper: `channelEventName(serviceName, channelName)`), which also makes them
|
|
276
|
+
easy to speak from non-JS clients (game engines, native apps).
|
|
277
|
+
|
|
278
|
+
When to use which:
|
|
279
|
+
|
|
280
|
+
| | Method | Channel |
|
|
281
|
+
| -------------- | ------------------------- | ------------------------ |
|
|
282
|
+
| Response | ack with data/error | none (fire-and-forget) |
|
|
283
|
+
| Frequency | occasional (user actions) | tick rate (10-60Hz) |
|
|
284
|
+
| Loss tolerance | must not lose | next message supersedes |
|
|
285
|
+
| ACL | full async check incl. DB | in-memory only |
|
|
286
|
+
| Rate limit | global limiter | per-channel token bucket |
|
|
287
|
+
|
|
183
288
|
## Package Exports
|
|
184
289
|
|
|
185
290
|
```typescript
|
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 UseServiceQueryOptions, d as UseServiceQueryResult, e as UseSubscriptionOptions, f as UseSubscriptionResult, g as UseRoomEventsOptions } 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 UseServiceQueryOptions, d as UseServiceQueryResult, e as UseSubscriptionOptions, f as UseSubscriptionResult, g as UseRoomEventsOptions, h as UseChannelSendResult } from '../types-DUHPIy_5.js';
|
|
3
|
+
export { C as ClientServiceMethodMap, S as SubscriptionDataMap } from '../types-DUHPIy_5.js';
|
|
4
4
|
import 'socket.io-client';
|
|
5
5
|
import '@tanstack/react-query';
|
|
6
6
|
import '../shared/index.js';
|
|
@@ -28,7 +28,7 @@ declare function useQuickdrawSocket(): QuickdrawSocketContextValue;
|
|
|
28
28
|
* }
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
|
-
declare function QuickdrawProvider({ children, serverUrl, queryClient, authToken, autoConnect, withCredentials, }: QuickdrawProviderProps): React.ReactElement;
|
|
31
|
+
declare function QuickdrawProvider({ children, serverUrl, queryClient, authToken, autoConnect, withCredentials, socketPath, }: QuickdrawProviderProps): React.ReactElement;
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Hook for invoking service methods via Socket.io with TanStack Query integration.
|
|
@@ -207,6 +207,36 @@ declare function useSubscription<TData extends {
|
|
|
207
207
|
*/
|
|
208
208
|
declare function useRoomEvents(handlers: Record<string, (data: never) => void>, options?: UseRoomEventsOptions): void;
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Hook for sending fire-and-forget channel messages (the high-frequency
|
|
212
|
+
* counterpart to useService).
|
|
213
|
+
*
|
|
214
|
+
* Channel messages are emitted volatile: if the connection is backpressured,
|
|
215
|
+
* the message is dropped instead of queued — the right behavior for input
|
|
216
|
+
* streams where the next message supersedes the last. There is no ack and no
|
|
217
|
+
* response; the server validates, rate-limits (per-channel token bucket), and
|
|
218
|
+
* access-checks each message, silently dropping any that fail.
|
|
219
|
+
*
|
|
220
|
+
* Receiving server broadcasts needs nothing new: volatile room emits arrive
|
|
221
|
+
* as ordinary events, so pair this with `useRoomEvents` (and `useSubscription`
|
|
222
|
+
* for room membership).
|
|
223
|
+
*
|
|
224
|
+
* @typeParam TPayload - The channel payload type (from the service's channel map)
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* ```tsx
|
|
228
|
+
* function GameInput({ worldId }: { worldId: string }) {
|
|
229
|
+
* // Room membership (ACL-checked server-side) gates the channel
|
|
230
|
+
* useSubscription("gameService", worldId);
|
|
231
|
+
* const { send, isReady } = useChannelSend<GameInput>("gameService", "input");
|
|
232
|
+
*
|
|
233
|
+
* // e.g. called from a 20Hz game loop
|
|
234
|
+
* const onTick = (input: GameInput) => send(input);
|
|
235
|
+
* }
|
|
236
|
+
* ```
|
|
237
|
+
*/
|
|
238
|
+
declare function useChannelSend<TPayload = unknown>(serviceName: string, channelName: string): UseChannelSendResult<TPayload>;
|
|
239
|
+
|
|
210
240
|
type CommitMode = "change" | "blur" | "debounce";
|
|
211
241
|
interface UseSocketInputOptions<TEntry, TAllowedUpdate, TKey extends keyof TAllowedUpdate> {
|
|
212
242
|
/**
|
|
@@ -517,4 +547,4 @@ declare function buildBreadcrumbs(pathname: string, navigation: NavItem[]): Brea
|
|
|
517
547
|
*/
|
|
518
548
|
declare function routeRequiresAuth(pathname: string, navigation: NavItem[], userMenuItems?: NavItem[]): boolean;
|
|
519
549
|
|
|
520
|
-
export { type BreadcrumbItem, type CommitMode, type JWTPayload, type NavItem, QuickdrawProvider, QuickdrawProviderProps, QuickdrawSocketContextValue, SocketCheckbox, type SocketCheckboxProps, SocketSelect, type SocketSelectProps, SocketSlider, type SocketSliderProps, SocketSwitch, type SocketSwitchProps, SocketTextField, type SocketTextFieldProps, UseRoomEventsOptions, UseServiceOptions, UseServiceQueryOptions, UseServiceQueryResult, UseServiceResult, type UseSocketInputOptions, type UseSocketInputResult, UseSubscriptionOptions, UseSubscriptionResult, buildBreadcrumbs, clearAuthToken, findNavItemByHref, findParentNavItem, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, getAuthToken, getOAuthUrl, logout, logoutAllDevices, parseJWTPayload, routeRequiresAuth, setAuthToken, truncate, useQuickdrawSocket, useRoomEvents, useService, useServiceMethod, useServiceQuery, useSocketInput, useSubscription };
|
|
550
|
+
export { type BreadcrumbItem, type CommitMode, type JWTPayload, type NavItem, QuickdrawProvider, QuickdrawProviderProps, QuickdrawSocketContextValue, SocketCheckbox, type SocketCheckboxProps, SocketSelect, type SocketSelectProps, SocketSlider, type SocketSliderProps, SocketSwitch, type SocketSwitchProps, SocketTextField, type SocketTextFieldProps, UseChannelSendResult, UseRoomEventsOptions, UseServiceOptions, UseServiceQueryOptions, UseServiceQueryResult, UseServiceResult, type UseSocketInputOptions, type UseSocketInputResult, UseSubscriptionOptions, UseSubscriptionResult, buildBreadcrumbs, clearAuthToken, findNavItemByHref, findParentNavItem, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, getAuthToken, getOAuthUrl, logout, logoutAllDevices, parseJWTPayload, routeRequiresAuth, setAuthToken, truncate, useChannelSend, useQuickdrawSocket, useRoomEvents, useService, useServiceMethod, useServiceQuery, useSocketInput, useSubscription };
|
package/dist/client/index.js
CHANGED
|
@@ -139,7 +139,8 @@ function QuickdrawProvider({
|
|
|
139
139
|
queryClient,
|
|
140
140
|
authToken,
|
|
141
141
|
autoConnect = !!authToken,
|
|
142
|
-
withCredentials = true
|
|
142
|
+
withCredentials = true,
|
|
143
|
+
socketPath
|
|
143
144
|
}) {
|
|
144
145
|
const [defaultQueryClient] = React.useState(() => createQueryClient());
|
|
145
146
|
const actualQueryClient = queryClient ?? defaultQueryClient;
|
|
@@ -163,7 +164,8 @@ function QuickdrawProvider({
|
|
|
163
164
|
auth: authToUse ? { token: authToUse } : void 0,
|
|
164
165
|
withCredentials,
|
|
165
166
|
transports: ["websocket", "polling"],
|
|
166
|
-
autoConnect: true
|
|
167
|
+
autoConnect: true,
|
|
168
|
+
...socketPath ? { path: socketPath } : {}
|
|
167
169
|
});
|
|
168
170
|
newSocket.on("connect", () => {
|
|
169
171
|
setIsConnected(true);
|
|
@@ -185,7 +187,7 @@ function QuickdrawProvider({
|
|
|
185
187
|
socketRef.current = newSocket;
|
|
186
188
|
setSocket(newSocket);
|
|
187
189
|
},
|
|
188
|
-
[serverUrl, withCredentials]
|
|
190
|
+
[serverUrl, withCredentials, socketPath]
|
|
189
191
|
);
|
|
190
192
|
const disconnect = React.useCallback(() => {
|
|
191
193
|
if (socketRef.current) {
|
|
@@ -664,6 +666,25 @@ function useRoomEvents(handlers, options) {
|
|
|
664
666
|
};
|
|
665
667
|
}, [socket, isConnected, enabled, stableEventNames]);
|
|
666
668
|
}
|
|
669
|
+
|
|
670
|
+
// src/shared/types.ts
|
|
671
|
+
function channelEventName(serviceName, channelName) {
|
|
672
|
+
return `channel:${serviceName}:${channelName}`;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/client/useChannelSend.ts
|
|
676
|
+
function useChannelSend(serviceName, channelName) {
|
|
677
|
+
const { socket, isConnected } = useQuickdrawSocket();
|
|
678
|
+
const eventName = channelEventName(serviceName, channelName);
|
|
679
|
+
const send = React.useCallback(
|
|
680
|
+
(payload) => {
|
|
681
|
+
if (!socket?.connected) return;
|
|
682
|
+
socket.volatile.emit(eventName, payload);
|
|
683
|
+
},
|
|
684
|
+
[socket, eventName]
|
|
685
|
+
);
|
|
686
|
+
return { send, isReady: isConnected && !!socket };
|
|
687
|
+
}
|
|
667
688
|
function useSocketInput(options) {
|
|
668
689
|
const {
|
|
669
690
|
state,
|
|
@@ -1194,6 +1215,6 @@ function routeRequiresAuth(pathname, navigation, userMenuItems) {
|
|
|
1194
1215
|
return false;
|
|
1195
1216
|
}
|
|
1196
1217
|
|
|
1197
|
-
export { QuickdrawProvider, SocketCheckbox, SocketSelect, SocketSlider, SocketSwitch, SocketTextField, buildBreadcrumbs, clearAuthToken, findNavItemByHref, findParentNavItem, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, getAuthToken, getOAuthUrl, logout, logoutAllDevices, parseJWTPayload, routeRequiresAuth, setAuthToken, truncate, useQuickdrawSocket, useRoomEvents, useService, useServiceMethod, useServiceQuery, useSocketInput, useSubscription };
|
|
1218
|
+
export { QuickdrawProvider, SocketCheckbox, SocketSelect, SocketSlider, SocketSwitch, SocketTextField, buildBreadcrumbs, clearAuthToken, findNavItemByHref, findParentNavItem, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, getAuthToken, getOAuthUrl, logout, logoutAllDevices, parseJWTPayload, routeRequiresAuth, setAuthToken, truncate, useChannelSend, useQuickdrawSocket, useRoomEvents, useService, useServiceMethod, useServiceQuery, useSocketInput, useSubscription };
|
|
1198
1219
|
//# sourceMappingURL=index.js.map
|
|
1199
1220
|
//# sourceMappingURL=index.js.map
|