@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 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
@@ -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-B1z5aXdR.js';
3
- export { C as ClientServiceMethodMap, S as SubscriptionDataMap } from '../types-B1z5aXdR.js';
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 };
@@ -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