@emeryld/rrroutes-client 2.2.9 → 2.2.11

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
@@ -1,6 +1,13 @@
1
+ <!--
2
+ Summary:
3
+ - Added comprehensive usage for `createRouteClient`, built endpoints (GET/feeds/mutations), cache helpers, invalidation, debug modes, custom fetchers, and React Query integration.
4
+ - Added new sections for router helpers, infinite/feeds, FormData uploads, and socket utilities (SocketClient, provider hooks, socketed routes).
5
+ - Removed the sparse README and outdated publishing script focus; future additions should cover server-side Socket.IO envelope expectations and testing recipes for hooks.
6
+ -->
7
+
1
8
  # @emeryld/rrroutes-client
2
9
 
3
- React Query helpers that sit on top of an RRRoutes contract. This package consumes finalized leaves from `@emeryld/rrroutes-contract` and produces typed fetch helpers (`useEndpoint`, `useInfinite`, `useMutation`, cache key utilities, etc.).
10
+ Typed React Query + Socket.IO helpers that sit on top of RRRoutes contracts. Build endpoints directly from finalized leaves, get strongly-typed hooks/fetchers, ready-to-use cache keys, debug logging, and optional socket utilities (client + React provider + socketed routes).
4
11
 
5
12
  ## Installation
6
13
 
@@ -10,63 +17,429 @@ pnpm add @emeryld/rrroutes-client @tanstack/react-query
10
17
  npm install @emeryld/rrroutes-client @tanstack/react-query
11
18
  ```
12
19
 
13
- The client already depends on `@emeryld/rrroutes-contract` and `zod`. You only need to provide React Query as a peer dependency.
20
+ `@emeryld/rrroutes-contract` and `zod` come along as dependencies; you supply React Query.
14
21
 
15
- ## Usage
22
+ ## Quick start (typed GET with React Query)
16
23
 
17
24
  ```ts
18
- import { createRouteClient } from '@emeryld/rrroutes-client';
19
25
  import { QueryClient } from '@tanstack/react-query';
20
- import { registry } from '../routes';
26
+ import { createRouteClient } from '@emeryld/rrroutes-client';
27
+ import { registry } from '../routes'; // from @emeryld/rrroutes-contract + finalize(...)
21
28
 
22
29
  const routeClient = createRouteClient({
23
- queryClient: new QueryClient(),
24
- baseUrl: '/api',
30
+ baseUrl: '/api', // prepended to all paths
31
+ queryClient: new QueryClient(), // shared React Query instance
25
32
  });
26
33
 
27
- const listUsers = routeClient.build(registry.byKey['GET /v1/users']);
34
+ const listUsers = routeClient.build(registry.byKey['GET /v1/users'], {
35
+ staleTime: 60_000,
36
+ onReceive: (data) => console.log('fresh users', data),
37
+ });
28
38
 
29
- function Users() {
39
+ export function Users() {
30
40
  const { data, isLoading } = listUsers.useEndpoint({ query: { search: 'emery' } });
31
41
  if (isLoading) return <p>Loading…</p>;
32
- return <pre>{JSON.stringify(data)}</pre>;
42
+ return <pre>{JSON.stringify(data, null, 2)}</pre>;
33
43
  }
34
44
  ```
35
45
 
36
- `createRouteClient` passes your `QueryClient` instance directly into every `useQuery`, `useInfiniteQuery`, and `useMutation` call. That means you can skip wrapping your app with `QueryClientProvider` if you prefer manual wiring (though it's still supported if you need context for devtools or hydration).
46
+ ## How it works
47
+
48
+ - `createRouteClient` wires your `QueryClient`, base URL, optional custom fetcher, and debug settings.
49
+ - `build(leaf, options?, meta?)` returns a helper that exposes:
50
+ - `useEndpoint(args?)` — React hook for GET/feeds/mutations (typed params/query/body/output).
51
+ - `fetch(...)` — direct fetcher (no cache). Mutations require the body as the last argument.
52
+ - `getQueryKeys(...)` — deterministic cache key used by React Query + invalidation.
53
+ - `invalidate(...)` — invalidate this exact endpoint instance.
54
+ - `setData(updater, args?)` — mutate cache (infinite-aware).
55
+ - For feed endpoints (`cfg.feed === true`), cursors are handled automatically; cache keys omit the cursor so pages merge correctly.
37
56
 
38
- ### Debug logging
57
+ ---
39
58
 
40
- You can opt into verbose logging by passing `debug` when constructing the client:
59
+ ## Detailed usage
60
+
61
+ ### 1) Configure the client
41
62
 
42
63
  ```ts
43
- const routeClient = createRouteClient({
64
+ import { QueryClient } from '@tanstack/react-query';
65
+ import { createRouteClient, defaultFetcher } from '@emeryld/rrroutes-client';
66
+
67
+ const queryClient = new QueryClient();
68
+
69
+ const client = createRouteClient({
70
+ baseUrl: 'https://api.example.com',
71
+ queryClient,
72
+ fetcher: async (req) => {
73
+ // Attach auth headers, reuse defaultFetcher for JSON parsing + error handling
74
+ return defaultFetcher({
75
+ ...req,
76
+ headers: { ...req.headers, Authorization: `Bearer ${getToken()}` },
77
+ });
78
+ },
79
+ cursorParam: 'cursor', // optional; default is "cursor"
80
+ getNextCursor: (page) => page?.links?.next ?? page?.nextCursor, // optional override
81
+ environment: process.env.NODE_ENV, // disables debug when "production"
82
+ debug: {
83
+ fetch: true,
84
+ invalidate: true,
85
+ verbose: true, // include params/query/output in debug events
86
+ },
87
+ });
88
+ ```
89
+
90
+ ### 2) Build endpoints from your registry
91
+
92
+ ```ts
93
+ import { registry } from '../routes';
94
+
95
+ // Plain GET
96
+ const getUser = client.build(registry.byKey['GET /v1/users/:userId'], {
97
+ staleTime: 30_000,
98
+ });
99
+
100
+ // Infinite/feed GET (cfg.feed === true)
101
+ const listFeed = client.build(registry.byKey['GET /v1/posts'], {
102
+ getNextPageParam: (last) => last.nextCursor, // React Query option override
103
+ });
104
+
105
+ // Mutation
106
+ const updateUser = client.build(registry.byKey['PATCH /v1/users/:userId'], {
107
+ onSuccess: () => client.invalidate(['get', 'v1', 'users']), // prefix invalidate
108
+ });
109
+ ```
110
+
111
+ ### 3) Use GET hooks (with params/query/body)
112
+
113
+ ```ts
114
+ type User = Awaited<ReturnType<typeof getUser.fetch>>; // fully typed output
115
+
116
+ function Profile({ userId }: { userId: string }) {
117
+ const result = getUser.useEndpoint({ params: { userId } });
118
+ if (result.isLoading) return <p>Loading…</p>;
119
+ if (result.error) return <p>Failed: {String(result.error)}</p>;
120
+
121
+ // Register a listener for push-based updates (e.g., sockets) against this hook
122
+ result.onReceive((freshUser) => {
123
+ console.log('pushed update', freshUser);
124
+ });
125
+
126
+ return <div>{result.data.name}</div>;
127
+ }
128
+ ```
129
+
130
+ - For GET leaves that define a `bodySchema`, pass the body after the args tuple:
131
+
132
+ ```ts
133
+ const auditStatus = client.build(registry.byKey['GET /v1/audit']);
134
+ await auditStatus.fetch({}, { includeExternal: true }); // body matches the leaf's bodySchema
135
+ ```
136
+
137
+ ### 4) Use infinite feeds
138
+
139
+ ```ts
140
+ function PostFeed() {
141
+ const feed = listFeed.useEndpoint({ query: { cursor: undefined, limit: 20 } });
142
+
143
+ return (
144
+ <>
145
+ {(feed.data?.pages ?? []).map((page) =>
146
+ page.items.map((post) => <article key={post.id}>{post.title}</article>),
147
+ )}
148
+ <button
149
+ disabled={!feed.hasNextPage || feed.isFetchingNextPage}
150
+ onClick={() => feed.fetchNextPage()}
151
+ >
152
+ Load more
153
+ </button>
154
+ </>
155
+ );
156
+ }
157
+ ```
158
+
159
+ - Cursor params are stripped from cache keys automatically so pages share the same base key.
160
+
161
+ ### 5) Use mutations (with optimistic cache helpers)
162
+
163
+ ```ts
164
+ async function rename(userId: string, name: string) {
165
+ // Direct fetch (server action / non-React usage)
166
+ await updateUser.fetch({ params: { userId } }, { name });
167
+ }
168
+
169
+ export function RenameForm({ userId }: { userId: string }) {
170
+ const mutation = updateUser.useEndpoint({ params: { userId } });
171
+
172
+ async function submit(e: React.FormEvent) {
173
+ e.preventDefault();
174
+ const name = new FormData(e.currentTarget).get('name') as string;
175
+
176
+ // Optimistically update cache for both the detail and list
177
+ updateUser.setData((prev) => (prev ? { ...prev, name } : prev), { params: { userId } });
178
+ client.invalidate(['get', 'v1', 'users']);
179
+
180
+ await mutation.mutateAsync({ name });
181
+ }
182
+
183
+ return (
184
+ <form onSubmit={submit}>
185
+ <input name="name" defaultValue="" />
186
+ <button disabled={mutation.isLoading}>Save</button>
187
+ {mutation.error && <p>Error: {String(mutation.error)}</p>}
188
+ </form>
189
+ );
190
+ }
191
+ ```
192
+
193
+ ### 6) Cache keys, invalidation, and manual cache writes
194
+
195
+ ```ts
196
+ const keys = getUser.getQueryKeys({ params: { userId: 'u_1' } }); // ['get','v1','users','u_1', {}]
197
+ await getUser.invalidate({ params: { userId: 'u_1' } }); // invalidate exact detail
198
+ await client.invalidate(['get', 'v1', 'users']); // invalidate any users endpoints
199
+
200
+ getUser.setData((prev) => (prev ? { ...prev, status: 'online' } : prev), {
201
+ params: { userId: 'u_1' },
202
+ });
203
+ ```
204
+
205
+ `setData` respects feeds (updates `InfiniteData` shape when `cfg.feed === true`).
206
+
207
+ ### 7) Router helper (build by name instead of leaf)
208
+
209
+ ```ts
210
+ import { buildRouter } from '@emeryld/rrroutes-client';
211
+ import { registry } from '../routes';
212
+
213
+ const routes = {
214
+ listUsers: registry.byKey['GET /v1/users'],
215
+ updateUser: registry.byKey['PATCH /v1/users/:userId'],
216
+ } as const;
217
+
218
+ const buildRoute = buildRouter(client, routes);
219
+
220
+ const listUsers = buildRoute('listUsers'); // builds from routes.listUsers
221
+ const updateUser = buildRoute('updateUser', {}, { name: 'profile' }); // debug name filtering
222
+ ```
223
+
224
+ ### 8) File uploads (FormData)
225
+
226
+ If a leaf has `bodyFiles` set in its contract, the client automatically converts the body to `FormData`:
227
+
228
+ ```ts
229
+ const uploadAvatar = client.build(registry.byKey['PUT /v1/users/:userId/avatar']);
230
+
231
+ await uploadAvatar.fetch(
232
+ { params: { userId: 'u_1' } },
233
+ { avatar: new File([blob], 'avatar.png', { type: 'image/png' }) },
234
+ );
235
+ ```
236
+
237
+ ### 9) Debug logging
238
+
239
+ ```ts
240
+ const client = createRouteClient({
44
241
  baseUrl: '/api',
45
- queryClient: new QueryClient(),
46
- debug: process.env.NODE_ENV !== 'production',
47
- // or customize the logger:
48
- // debug: {
49
- // enabled: true,
50
- // logger: (event) => console.info('[rrroutes-client]', event),
51
- // },
242
+ queryClient,
243
+ debug: {
244
+ build: true,
245
+ fetch: true,
246
+ invalidate: true,
247
+ setData: true,
248
+ useEndpoint: true,
249
+ verbose: true,
250
+ // Limit to specific endpoints by name (third arg to build)
251
+ only: ['profile', 'feed'],
252
+ logger: (e) => console.info('[rrroutes-client]', e),
253
+ },
52
254
  });
255
+
256
+ const profile = client.build(registry.byKey['GET /v1/me'], {}, { name: 'profile' });
53
257
  ```
54
258
 
55
- Events cover fetch lifecycles (`start`/`success`/`error`), cache updates, and invalidations so you can trace how RRRoutes interacts with React Query.
259
+ Set `environment: 'production'` to silence all debug output regardless of the `debug` option.
56
260
 
57
- ## Scripts
261
+ ---
58
262
 
59
- ```sh
60
- pnpm install
61
- pnpm --filter @emeryld/rrroutes-client build
62
- pnpm --filter @emeryld/rrroutes-client test
263
+ ## Socket utilities (optional)
264
+
265
+ The package also ships a typed Socket.IO client, React provider hooks, and a helper to merge socket events into React Query caches.
266
+
267
+ ### Define events + config
268
+
269
+ ```ts
270
+ import { z } from 'zod';
271
+ import { defineSocketEvents } from '@emeryld/rrroutes-contract';
272
+
273
+ const { events, config } = defineSocketEvents(
274
+ {
275
+ joinMetaMessage: z.object({ source: z.string().optional() }),
276
+ leaveMetaMessage: z.object({ source: z.string().optional() }),
277
+ pingPayload: z.object({ clientEcho: z.object({ sentAt: z.string() }).optional() }),
278
+ pongPayload: z.object({
279
+ clientEcho: z.object({ sentAt: z.string() }).optional(),
280
+ sinceMs: z.number().optional(),
281
+ }),
282
+ },
283
+ {
284
+ 'chat:message': { message: z.object({ roomId: z.string(), text: z.string(), userId: z.string() }) },
285
+ },
286
+ );
63
287
  ```
64
288
 
65
- ## Publishing
289
+ ### Vanilla SocketClient
66
290
 
67
- ```sh
68
- cd packages/client
69
- npm publish --access public
291
+ ```ts
292
+ import { io } from 'socket.io-client';
293
+ import { SocketClient } from '@emeryld/rrroutes-client';
294
+ import { events, config } from './socketContract';
295
+
296
+ const socket = io('https://socket.example.com', { transports: ['websocket'] });
297
+
298
+ const client = new SocketClient(events, {
299
+ socket,
300
+ config,
301
+ sys: {
302
+ 'sys:ping': async () => ({
303
+ clientEcho: { sentAt: new Date().toISOString() },
304
+ }),
305
+ 'sys:pong': async ({ payload }) => {
306
+ console.log('pong latency', payload.sinceMs);
307
+ },
308
+ 'sys:room_join': async ({ rooms }) => {
309
+ console.log('joining rooms', rooms);
310
+ return true; // allow join
311
+ },
312
+ 'sys:room_leave': async ({ rooms }) => {
313
+ console.log('leaving rooms', rooms);
314
+ return true;
315
+ },
316
+ },
317
+ heartbeat: { intervalMs: 15_000, timeoutMs: 7_500 },
318
+ debug: { receive: true, emit: true, verbose: true, logger: console.log },
319
+ });
320
+
321
+ client.on('chat:message', (payload, { ctx }) => {
322
+ console.log('socket message', payload.text, 'latency', ctx.latencyMs);
323
+ });
324
+
325
+ void client.emit('chat:message', { roomId: 'general', text: 'hi', userId: 'u_1' });
326
+ ```
327
+
328
+ Key methods: `emit`, `on`, `joinRooms` / `leaveRooms`, `startHeartbeat` / `stopHeartbeat`, `connect` / `disconnect`, `stats()`, `destroy()`.
329
+
330
+ ### React provider + hooks
331
+
332
+ ```ts
333
+ import { buildSocketProvider } from '@emeryld/rrroutes-client';
334
+ import { io } from 'socket.io-client';
335
+ import { events, config } from './socketContract';
336
+
337
+ const { SocketProvider, useSocketClient, useSocketConnection } = buildSocketProvider({
338
+ events,
339
+ options: {
340
+ config,
341
+ sys: {
342
+ 'sys:ping': async () => ({ clientEcho: { sentAt: new Date().toISOString() } }),
343
+ 'sys:pong': async () => {},
344
+ 'sys:room_join': async () => true,
345
+ 'sys:room_leave': async () => true,
346
+ },
347
+ heartbeat: { intervalMs: 10_000 },
348
+ debug: { receive: true, hook: true, logger: console.log },
349
+ },
350
+ });
351
+
352
+ function App({ children }: { children: React.ReactNode }) {
353
+ return (
354
+ <SocketProvider
355
+ getSocket={() => io('https://socket.example.com')}
356
+ destroyLeaveMeta={{ source: 'app:unmount' }}
357
+ fallback={<p>Connecting…</p>}
358
+ >
359
+ {children}
360
+ </SocketProvider>
361
+ );
362
+ }
363
+
364
+ function RoomMessages({ roomId }: { roomId: string }) {
365
+ const client = useSocketClient<typeof events, typeof config>();
366
+
367
+ useSocketConnection({
368
+ event: 'chat:message',
369
+ rooms: roomId,
370
+ joinMeta: { source: 'room-hydration' },
371
+ leaveMeta: { source: 'room-hydration' },
372
+ onMessage: (payload) => console.log('message for room', payload.text),
373
+ });
374
+
375
+ return (
376
+ <button onClick={() => client.emit('chat:message', { roomId, text: 'ping', userId: 'me' })}>
377
+ Send
378
+ </button>
379
+ );
380
+ }
381
+ ```
382
+
383
+ ### Socket + React Query: `buildSocketedRoute`
384
+
385
+ Automatically join rooms based on fetched data and patch the cache when socket messages arrive.
386
+
387
+ ```ts
388
+ import { buildSocketedRoute } from '@emeryld/rrroutes-client';
389
+ import { useSocketClient } from './socketProvider';
390
+
391
+ const listRooms = client.build(registry.byKey['GET /v1/rooms'], { staleTime: 120_000 });
392
+
393
+ const useSocketedRooms = buildSocketedRoute({
394
+ built: listRooms,
395
+ event: 'chat:message',
396
+ toRooms: (page) => page.items.map((r) => r.id), // derive rooms from data (feeds supported)
397
+ joinMeta: { source: 'rooms:list' },
398
+ leaveMeta: { source: 'rooms:list' },
399
+ useSocketClient,
400
+ applyMessage: (prev, payload) => {
401
+ if (!prev) return prev;
402
+ // Example: bump unread count in cache
403
+ const apply = (items: any[]) =>
404
+ items.map((room) =>
405
+ room.id === payload.roomId ? { ...room, unread: (room.unread ?? 0) + 1 } : room,
406
+ );
407
+ return 'pages' in prev
408
+ ? { ...prev, pages: prev.pages.map((p) => ({ ...p, items: apply(p.items) })) }
409
+ : { ...prev, items: apply(prev.items) };
410
+ },
411
+ });
412
+
413
+ function RoomList() {
414
+ const { data, rooms } = useSocketedRooms();
415
+ return (
416
+ <>
417
+ <p>Subscribed rooms: {rooms.join(', ')}</p>
418
+ <ul>{data?.items.map((r) => <li key={r.id}>{r.name}</li>)}</ul>
419
+ </>
420
+ );
421
+ }
70
422
  ```
71
423
 
72
- Always bump `packages/client/package.json` before publishing so consumers can pick up the new release.
424
+ ---
425
+
426
+ ## Edge cases & notes
427
+
428
+ - Mutation `fetch` requires a body argument; GET `fetch` only requires a body when the leaf defines one.
429
+ - Path and query params are validated with the contract schemas before fetch; missing params throw synchronously.
430
+ - Query objects that contain arrays/objects are JSON-stringified in the URL query string.
431
+ - Feed cache keys omit the cursor so `invalidate(['get','v1','posts'])` clears all pages.
432
+ - `setData` runs your updater against the current cache value; return `undefined` to leave the cache untouched.
433
+ - When `environment` is `'production'`, debug logs are disabled even if `debug` is set.
434
+
435
+ ---
436
+
437
+ ## Scripts (monorepo)
438
+
439
+ Run from the repo root:
440
+
441
+ ```sh
442
+ pnpm --filter @emeryld/rrroutes-client build
443
+ pnpm --filter @emeryld/rrroutes-client typecheck
444
+ pnpm --filter @emeryld/rrroutes-client test
445
+ ```
package/dist/index.cjs CHANGED
@@ -746,9 +746,9 @@ function roomsFromData(data, toRooms) {
746
746
  return Array.from(merge);
747
747
  }
748
748
  function buildSocketedRoute(options) {
749
- const { built, event, toRooms, applyMessage, joinMeta, leaveMeta, autoJoin = true, autoLeave = true } = options;
749
+ const { built, event, toRooms, applyMessage, joinMeta, leaveMeta, useSocketClient: useSocketClient2 } = options;
750
750
  return (...useArgs) => {
751
- const client = useSocketClient();
751
+ const client = useSocketClient2();
752
752
  const endpointResult = built.useEndpoint(...useArgs);
753
753
  const argsKey = (0, import_react2.useMemo)(() => JSON.stringify(useArgs[0] ?? null), [useArgs]);
754
754
  const [rooms, setRooms] = (0, import_react2.useState)(
@@ -771,7 +771,7 @@ function buildSocketedRoute(options) {
771
771
  setRooms(roomsFromData(endpointResult.data, toRooms));
772
772
  }, [endpointResult.data, toRooms]);
773
773
  (0, import_react2.useEffect)(() => {
774
- if (!autoJoin || rooms.length === 0) return;
774
+ if (rooms.length === 0) return;
775
775
  let active = true;
776
776
  (async () => {
777
777
  try {
@@ -780,12 +780,12 @@ function buildSocketedRoute(options) {
780
780
  }
781
781
  })();
782
782
  return () => {
783
- if (!active || !autoLeave || rooms.length === 0) return;
783
+ if (!active || rooms.length === 0) return;
784
784
  active = false;
785
785
  void client.leaveRooms(rooms, leaveMeta).catch(() => {
786
786
  });
787
787
  };
788
- }, [client, joinMeta, leaveMeta, autoJoin, autoLeave, roomsKey]);
788
+ }, [client, joinMeta, leaveMeta, roomsKey]);
789
789
  (0, import_react2.useEffect)(() => {
790
790
  const unsubscribe = client.on(event, (payload, meta) => {
791
791
  built.setData((prev) => {