@fitzzero/quickdraw-core 3.6.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.
Files changed (37) hide show
  1. package/README.md +218 -64
  2. package/dist/client/index.d.ts +34 -4
  3. package/dist/client/index.js +57 -65
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/client/testing.d.ts +1 -1
  6. package/dist/client/testing.js.map +1 -1
  7. package/dist/server/express/index.d.ts +37 -0
  8. package/dist/server/express/index.js +60 -0
  9. package/dist/server/express/index.js.map +1 -0
  10. package/dist/server/index.d.ts +330 -5
  11. package/dist/server/index.js +593 -144
  12. package/dist/server/index.js.map +1 -1
  13. package/dist/server/testing-prisma.d.ts +101 -0
  14. package/dist/server/testing-prisma.js +172 -0
  15. package/dist/server/testing-prisma.js.map +1 -0
  16. package/dist/server/testing.d.ts +1 -1
  17. package/dist/server/testing.js +87 -22
  18. package/dist/server/testing.js.map +1 -1
  19. package/dist/shared/index.d.ts +77 -1
  20. package/dist/shared/index.js +5 -1
  21. package/dist/shared/index.js.map +1 -1
  22. package/dist/{types-DniMxkQs.d.ts → types-ClgSr90l.d.ts} +5 -1
  23. package/dist/{types-B1z5aXdR.d.ts → types-DUHPIy_5.d.ts} +20 -1
  24. package/eslint-config/index.mjs +1 -2
  25. package/eslint-plugin-quickdraw/index.mjs +15 -7
  26. package/eslint-plugin-quickdraw/rules/no-cross-service-mutations.mjs +31 -32
  27. package/eslint-plugin-quickdraw/rules/no-direct-prisma-mutations.mjs +17 -18
  28. package/eslint-plugin-quickdraw/rules/no-raw-button-strings.mjs +52 -0
  29. package/eslint-plugin-quickdraw/rules/no-raw-service-room-string.mjs +140 -0
  30. package/eslint-plugin-quickdraw/rules/no-raw-socket-emit.mjs +20 -22
  31. package/eslint-plugin-quickdraw/rules/no-raw-socket-on.mjs +17 -17
  32. package/eslint-plugin-quickdraw/rules/no-raw-tooltip-strings.mjs +59 -0
  33. package/eslint-plugin-quickdraw/rules/no-raw-typography-strings.mjs +52 -0
  34. package/eslint-plugin-quickdraw/rules/no-service-method-record.mjs +3 -3
  35. package/eslint-plugin-quickdraw/rules/no-unsafe-payload-cast.mjs +8 -8
  36. package/eslint-plugin-quickdraw/rules/require-zod-schema.mjs +15 -20
  37. package/package.json +74 -50
package/README.md CHANGED
@@ -21,31 +21,36 @@ pnpm add @fitzzero/quickdraw-core
21
21
  ### Server Setup
22
22
 
23
23
  ```typescript
24
- import { createQuickdrawServer, BaseService } from '@fitzzero/quickdraw-core/server';
25
- import { PrismaClient } from '@prisma/client';
24
+ import { createQuickdrawServer, BaseService } from "@fitzzero/quickdraw-core/server";
25
+ import { PrismaClient } from "@prisma/client";
26
26
 
27
27
  const prisma = new PrismaClient();
28
28
 
29
29
  // Define your service
30
- class ChatService extends BaseService<Chat, Prisma.ChatCreateInput, Prisma.ChatUpdateInput, ChatServiceMethods> {
30
+ class ChatService extends BaseService<
31
+ Chat,
32
+ Prisma.ChatCreateInput,
33
+ Prisma.ChatUpdateInput,
34
+ ChatServiceMethods
35
+ > {
31
36
  constructor() {
32
- super({ serviceName: 'chatService', hasEntryACL: true });
37
+ super({ serviceName: "chatService", hasEntryACL: true });
33
38
  this.setDelegate(prisma.chat);
34
-
39
+
35
40
  // Define public methods
36
- this.createChat = this.defineMethod('createChat', 'Read', async (payload, ctx) => {
41
+ this.createChat = this.defineMethod("createChat", "Read", async (payload, ctx) => {
37
42
  const chat = await this.create({ title: payload.title, ownerId: ctx.userId });
38
43
  return { id: chat.id };
39
44
  });
40
45
  }
41
-
42
- createChat: ReturnType<typeof this.defineMethod<'createChat'>>;
46
+
47
+ createChat: ReturnType<typeof this.defineMethod<"createChat">>;
43
48
  }
44
49
 
45
50
  // Start server
46
51
  const { io, httpServer } = createQuickdrawServer({
47
52
  port: 4000,
48
- cors: { origin: 'http://localhost:3000' },
53
+ cors: { origin: "http://localhost:3000" },
49
54
  services: {
50
55
  chatService: new ChatService(),
51
56
  },
@@ -62,45 +67,42 @@ const { io, httpServer } = createQuickdrawServer({
62
67
 
63
68
  ```tsx
64
69
  // app/layout.tsx
65
- import { QuickdrawProvider } from '@fitzzero/quickdraw-core/client';
70
+ import { QuickdrawProvider } from "@fitzzero/quickdraw-core/client";
66
71
 
67
72
  export default function RootLayout({ children }) {
68
73
  return (
69
- <QuickdrawProvider
70
- serverUrl="http://localhost:4000"
71
- authToken={getAuthToken()}
72
- >
74
+ <QuickdrawProvider serverUrl="http://localhost:4000" authToken={getAuthToken()}>
73
75
  {children}
74
76
  </QuickdrawProvider>
75
77
  );
76
78
  }
77
79
 
78
80
  // app/chat/page.tsx
79
- import { useService, useSubscription, useRoomEvents } from '@fitzzero/quickdraw-core/client';
81
+ import { useService, useSubscription, useRoomEvents } from "@fitzzero/quickdraw-core/client";
80
82
 
81
83
  function ChatPage({ chatId }: { chatId: string }) {
82
84
  // Subscribe to real-time entity updates
83
- const { data: chat, isLoading } = useSubscription('chatService', chatId);
84
-
85
+ const { data: chat, isLoading } = useSubscription("chatService", chatId);
86
+
85
87
  // Mutation hook
86
- const updateTitle = useService('chatService', 'updateTitle', {
87
- onSuccess: () => console.log('Title updated!'),
88
+ const updateTitle = useService("chatService", "updateTitle", {
89
+ onSuccess: () => console.log("Title updated!"),
88
90
  });
89
-
91
+
90
92
  // Listen for custom events broadcast to the chat room
91
93
  const [typing, setTyping] = useState(false);
92
94
  useRoomEvents({
93
- 'chat:message': (msg) => appendMessage(msg),
94
- 'agent_typing_start': () => setTyping(true),
95
- 'agent_typing_stop': () => setTyping(false),
95
+ "chat:message": (msg) => appendMessage(msg),
96
+ agent_typing_start: () => setTyping(true),
97
+ agent_typing_stop: () => setTyping(false),
96
98
  });
97
-
99
+
98
100
  if (isLoading) return <div>Loading...</div>;
99
-
101
+
100
102
  return (
101
103
  <div>
102
104
  <h1>{chat?.title}</h1>
103
- <button onClick={() => updateTitle.mutate({ id: chatId, title: 'New Title' })}>
105
+ <button onClick={() => updateTitle.mutate({ id: chatId, title: "New Title" })}>
104
106
  Update Title
105
107
  </button>
106
108
  </div>
@@ -111,7 +113,7 @@ function ChatPage({ chatId }: { chatId: string }) {
111
113
  ### Socket Inputs
112
114
 
113
115
  ```tsx
114
- import { SocketTextField } from '@fitzzero/quickdraw-core/client';
116
+ import { SocketTextField } from "@fitzzero/quickdraw-core/client";
115
117
 
116
118
  function ChatTitleEditor({ chat, updateChat }) {
117
119
  return (
@@ -132,16 +134,16 @@ function ChatTitleEditor({ chat, updateChat }) {
132
134
  When your server broadcasts custom events to subscription rooms via `emitToRoom`, use `useRoomEvents` on the client to listen with proper lifecycle management:
133
135
 
134
136
  ```tsx
135
- import { useSubscription, useRoomEvents } from '@fitzzero/quickdraw-core/client';
137
+ import { useSubscription, useRoomEvents } from "@fitzzero/quickdraw-core/client";
136
138
 
137
139
  function ProjectBoard({ projectId }: { projectId: string }) {
138
- const { data: project } = useSubscription('projectService', projectId);
140
+ const { data: project } = useSubscription("projectService", projectId);
139
141
  const [tasks, setTasks] = useState<Task[]>([]);
140
142
 
141
143
  // Lifecycle-managed event listeners — cleanup handled automatically
142
144
  useRoomEvents({
143
- 'task:created': (task: Task) => setTasks(prev => [...prev, task]),
144
- 'task:deleted': ({ id }: { id: string }) => setTasks(prev => prev.filter(t => t.id !== id)),
145
+ "task:created": (task: Task) => setTasks((prev) => [...prev, task]),
146
+ "task:deleted": ({ id }: { id: string }) => setTasks((prev) => prev.filter((t) => t.id !== id)),
145
147
  });
146
148
 
147
149
  return <Board tasks={tasks} />;
@@ -153,39 +155,183 @@ function ProjectBoard({ projectId }: { projectId: string }) {
153
155
  For list queries that should refresh when related events fire, use `invalidateOn`:
154
156
 
155
157
  ```tsx
156
- import { useServiceQuery } from '@fitzzero/quickdraw-core/client';
158
+ import { useServiceQuery } from "@fitzzero/quickdraw-core/client";
157
159
 
158
160
  function TaskList({ projectId }: { projectId: string }) {
159
161
  // Auto-refetches when tasks are created, deleted, or change status
160
- const { data: tasks } = useServiceQuery('taskService', 'listTasks', { projectId }, {
161
- invalidateOn: ['task:created', 'task:deleted', 'task:statusUpdate'],
162
- });
162
+ const { data: tasks } = useServiceQuery(
163
+ "taskService",
164
+ "listTasks",
165
+ { projectId },
166
+ {
167
+ invalidateOn: ["task:created", "task:deleted", "task:statusUpdate"],
168
+ },
169
+ );
163
170
 
164
- return <ul>{tasks?.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
171
+ return (
172
+ <ul>
173
+ {tasks?.map((t) => (
174
+ <li key={t.id}>{t.title}</li>
175
+ ))}
176
+ </ul>
177
+ );
165
178
  }
166
179
  ```
167
180
 
168
181
  Rapid-fire events within 100ms are debounced into a single refetch.
169
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
+
170
288
  ## Package Exports
171
289
 
172
290
  ```typescript
173
291
  // Shared types (both server and client)
174
- import { ServiceResponse, AccessLevel, ServiceMethodMap } from '@fitzzero/quickdraw-core';
292
+ import { ServiceResponse, AccessLevel, ServiceMethodMap } from "@fitzzero/quickdraw-core";
175
293
 
176
294
  // Server
177
- import {
178
- BaseService,
179
- ServiceRegistry,
295
+ import {
296
+ BaseService,
297
+ ServiceRegistry,
180
298
  createQuickdrawServer,
181
299
  createJWT,
182
300
  verifyJWT,
183
301
  discordProvider,
184
302
  googleProvider,
185
- } from '@fitzzero/quickdraw-core/server';
303
+ // Auth & security (3.7+)
304
+ createMockOAuthProvider,
305
+ registerMockOAuthProvider,
306
+ isMockOAuthEnabled,
307
+ validateRedirectOrigin,
308
+ setSessionCookie,
309
+ clearSessionCookie,
310
+ createRequireAuth,
311
+ encrypt,
312
+ decrypt,
313
+ } from "@fitzzero/quickdraw-core/server";
314
+
315
+ // Express rate-limit presets (3.7+, requires the optional express-rate-limit peer)
316
+ import {
317
+ createAuthLimiter,
318
+ createWebhookLimiter,
319
+ createPublicApiLimiter,
320
+ } from "@fitzzero/quickdraw-core/server/express";
186
321
 
187
322
  // Server testing
188
- import { createTestServer, connectAsUser, emitWithAck } from '@fitzzero/quickdraw-core/server/testing';
323
+ import {
324
+ createTestServer,
325
+ connectAsUser,
326
+ emitWithAck,
327
+ } from "@fitzzero/quickdraw-core/server/testing";
328
+
329
+ // Dual-mode Prisma test databases (3.7+, optional peers: @electric-sql/pglite, pg)
330
+ import {
331
+ createPrismaTestGlobalSetup,
332
+ resetDatabase,
333
+ workerDatabaseUrl,
334
+ } from "@fitzzero/quickdraw-core/server/testing/prisma";
189
335
 
190
336
  // Client
191
337
  import {
@@ -200,10 +346,10 @@ import {
200
346
  SocketSelect,
201
347
  SocketSlider,
202
348
  SocketSwitch,
203
- } from '@fitzzero/quickdraw-core/client';
349
+ } from "@fitzzero/quickdraw-core/client";
204
350
 
205
351
  // Client testing
206
- import { createMockSocket, createTestWrapper } from '@fitzzero/quickdraw-core/client/testing';
352
+ import { createMockSocket, createTestWrapper } from "@fitzzero/quickdraw-core/client/testing";
207
353
  ```
208
354
 
209
355
  ## Local Development
@@ -226,7 +372,7 @@ Define your service methods in a shared types file:
226
372
 
227
373
  ```typescript
228
374
  // shared/types.ts
229
- import type { ServiceMethodMap } from '@fitzzero/quickdraw-core';
375
+ import type { ServiceMethodMap } from "@fitzzero/quickdraw-core";
230
376
 
231
377
  export type ChatServiceMethods = ServiceMethodMap<{
232
378
  createChat: {
@@ -238,7 +384,7 @@ export type ChatServiceMethods = ServiceMethodMap<{
238
384
  response: { id: string; title: string };
239
385
  };
240
386
  inviteUser: {
241
- payload: { id: string; userId: string; level: 'Read' | 'Moderate' | 'Admin' };
387
+ payload: { id: string; userId: string; level: "Read" | "Moderate" | "Admin" };
242
388
  response: { id: string };
243
389
  };
244
390
  }>;
@@ -309,6 +455,7 @@ Per-entity permissions. Quickdraw supports two patterns:
309
455
  #### Pattern 1: JSON ACL (Simple)
310
456
 
311
457
  Store ACL directly on the entity. Best for:
458
+
312
459
  - Simple ownership models (owner + collaborators)
313
460
  - When you don't need to query "all entities user X can access" efficiently
314
461
  - Minimal schema complexity
@@ -338,6 +485,7 @@ class DocumentService extends BaseService<Document, ...> {
338
485
  #### Pattern 2: Membership Table (Complex)
339
486
 
340
487
  Separate table for memberships. Best for:
488
+
341
489
  - Querying "all entities user X can access" efficiently
342
490
  - Complex role hierarchies
343
491
  - Additional membership metadata (join date, invited by, etc.)
@@ -353,7 +501,7 @@ model ChatMember {
353
501
  chatId String
354
502
  userId String
355
503
  level String // "Read" | "Moderate" | "Admin"
356
-
504
+
357
505
  @@unique([chatId, userId])
358
506
  }
359
507
 
@@ -384,35 +532,41 @@ When a method is called, `ensureAccessForMethod` checks in this order:
384
532
 
385
533
  ### Access Levels
386
534
 
387
- | Level | Value | Typical Use |
388
- |-------|-------|-------------|
389
- | Public | 0 | No authentication required |
390
- | Read | 1 | View data, subscribe to updates |
391
- | Moderate | 2 | Edit content, manage members |
392
- | Admin | 3 | Delete, manage ACL, full control |
535
+ | Level | Value | Typical Use |
536
+ | -------- | ----- | -------------------------------- |
537
+ | Public | 0 | No authentication required |
538
+ | Read | 1 | View data, subscribe to updates |
539
+ | Moderate | 2 | Edit content, manage members |
540
+ | Admin | 3 | Delete, manage ACL, full control |
393
541
 
394
542
  ## Testing
395
543
 
396
544
  ### Server Integration Tests
397
545
 
398
546
  ```typescript
399
- import { createTestServer, connectAsUser, emitWithAck } from '@fitzzero/quickdraw-core/server/testing';
547
+ import {
548
+ createTestServer,
549
+ connectAsUser,
550
+ emitWithAck,
551
+ } from "@fitzzero/quickdraw-core/server/testing";
400
552
 
401
- describe('ChatService', () => {
553
+ describe("ChatService", () => {
402
554
  let server;
403
-
555
+
404
556
  beforeAll(async () => {
405
557
  server = await createTestServer({
406
558
  services: { chatService: new ChatService() },
407
- seedDb: async () => { /* seed test data */ },
559
+ seedDb: async () => {
560
+ /* seed test data */
561
+ },
408
562
  });
409
563
  });
410
-
564
+
411
565
  afterAll(() => server.stop());
412
-
413
- it('creates chat', async () => {
414
- const client = await server.connectAs('user-id');
415
- const chat = await client.emit('chatService:createChat', { title: 'Test' });
566
+
567
+ it("creates chat", async () => {
568
+ const client = await server.connectAs("user-id");
569
+ const chat = await client.emit("chatService:createChat", { title: "Test" });
416
570
  expect(chat.id).toBeDefined();
417
571
  client.close();
418
572
  });
@@ -427,10 +581,10 @@ import { createTestWrapper, createMockSocket, mockSuccessEmit } from '@fitzzero/
427
581
  test('renders chat', () => {
428
582
  const mockSocket = createMockSocket();
429
583
  mockSocket.emit.mockImplementation(mockSuccessEmit({ title: 'Test Chat' }));
430
-
584
+
431
585
  const wrapper = createTestWrapper({ socketContext: { socket: mockSocket } });
432
586
  render(<ChatView chatId="123" />, { wrapper });
433
-
587
+
434
588
  expect(screen.getByText('Test Chat')).toBeInTheDocument();
435
589
  });
436
590
  ```
@@ -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 };