@emeryld/rrroutes-client 2.2.10 → 2.2.12
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 +405 -32
- package/dist/index.cjs +67 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +67 -12
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
20
|
+
`@emeryld/rrroutes-contract` and `zod` come along as dependencies; you supply React Query.
|
|
14
21
|
|
|
15
|
-
##
|
|
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 {
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
+
---
|
|
39
58
|
|
|
40
|
-
|
|
59
|
+
## Detailed usage
|
|
60
|
+
|
|
61
|
+
### 1) Configure the client
|
|
41
62
|
|
|
42
63
|
```ts
|
|
43
|
-
|
|
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
|
|
46
|
-
debug:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
259
|
+
Set `environment: 'production'` to silence all debug output regardless of the `debug` option.
|
|
56
260
|
|
|
57
|
-
|
|
261
|
+
---
|
|
58
262
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
289
|
+
### Vanilla SocketClient
|
|
66
290
|
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
@@ -71,8 +71,17 @@ var defaultFetcher = async (req) => {
|
|
|
71
71
|
// src/routesV3.client.index.ts
|
|
72
72
|
var import_react = require("react");
|
|
73
73
|
var import_react_query = require("@tanstack/react-query");
|
|
74
|
+
var import_zod = require("zod");
|
|
74
75
|
var import_rrroutes_contract = require("@emeryld/rrroutes-contract");
|
|
75
76
|
var toUpper = (m) => m.toUpperCase();
|
|
77
|
+
var defaultFeedQuerySchema = import_zod.z.object({
|
|
78
|
+
cursor: import_zod.z.string().optional(),
|
|
79
|
+
limit: import_zod.z.coerce.number().min(1).max(100).default(20)
|
|
80
|
+
});
|
|
81
|
+
var defaultFeedOutputSchema = import_zod.z.object({
|
|
82
|
+
items: import_zod.z.array(import_zod.z.unknown()),
|
|
83
|
+
nextCursor: import_zod.z.string().optional()
|
|
84
|
+
});
|
|
76
85
|
function zParse(value, schema) {
|
|
77
86
|
return schema ? schema.parse(value) : value;
|
|
78
87
|
}
|
|
@@ -174,6 +183,37 @@ function extractArgs(args) {
|
|
|
174
183
|
function toArgsTuple(args) {
|
|
175
184
|
return typeof args === "undefined" ? [] : [args];
|
|
176
185
|
}
|
|
186
|
+
function augmentFeedQuerySchema(schema) {
|
|
187
|
+
if (!schema) return defaultFeedQuerySchema;
|
|
188
|
+
if (schema instanceof import_zod.z.ZodObject) {
|
|
189
|
+
const shape = schema.shape ? schema.shape : schema._def?.shape?.();
|
|
190
|
+
return schema.extend({
|
|
191
|
+
...shape ?? {},
|
|
192
|
+
cursor: defaultFeedQuerySchema.shape.cursor,
|
|
193
|
+
limit: defaultFeedQuerySchema.shape.limit
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return import_zod.z.intersection(schema, defaultFeedQuerySchema);
|
|
197
|
+
}
|
|
198
|
+
function augmentFeedOutputSchema(schema) {
|
|
199
|
+
if (!schema) return defaultFeedOutputSchema;
|
|
200
|
+
if (schema instanceof import_zod.z.ZodObject) {
|
|
201
|
+
const shape = schema.shape ? schema.shape : schema._def?.shape?.();
|
|
202
|
+
const hasItems = Boolean(shape?.items);
|
|
203
|
+
if (hasItems) return schema.extend({ nextCursor: import_zod.z.string().optional() });
|
|
204
|
+
return import_zod.z.object({
|
|
205
|
+
items: import_zod.z.array(schema),
|
|
206
|
+
nextCursor: import_zod.z.string().optional()
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (schema instanceof import_zod.z.ZodArray) {
|
|
210
|
+
return import_zod.z.object({
|
|
211
|
+
items: schema,
|
|
212
|
+
nextCursor: import_zod.z.string().optional()
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return defaultFeedOutputSchema;
|
|
216
|
+
}
|
|
177
217
|
function buildUrl(leaf, baseUrl, params, query) {
|
|
178
218
|
const normalizedParams = zParse(params, leaf.cfg.paramsSchema);
|
|
179
219
|
const normalizedQuery = zParse(query, leaf.cfg.querySchema);
|
|
@@ -202,8 +242,13 @@ function createRouteClient(opts) {
|
|
|
202
242
|
function buildInternal(leaf, rqOpts, meta) {
|
|
203
243
|
const isGet = leaf.method === "get";
|
|
204
244
|
const isFeed = !!leaf.cfg.feed;
|
|
245
|
+
const leafCfg = isFeed ? {
|
|
246
|
+
...leaf.cfg,
|
|
247
|
+
querySchema: augmentFeedQuerySchema(leaf.cfg.querySchema),
|
|
248
|
+
outputSchema: augmentFeedOutputSchema(leaf.cfg.outputSchema)
|
|
249
|
+
} : leaf.cfg;
|
|
205
250
|
const method = toUpper(leaf.method);
|
|
206
|
-
const expectsArgs = Boolean(
|
|
251
|
+
const expectsArgs = Boolean(leafCfg.paramsSchema || leafCfg.querySchema);
|
|
207
252
|
const leafLabel = `${leaf.method.toUpperCase()} ${String(leaf.path)}`;
|
|
208
253
|
const debugName = meta?.name;
|
|
209
254
|
const emit = (event) => emitDebug(event, debugName);
|
|
@@ -247,13 +292,18 @@ function createRouteClient(opts) {
|
|
|
247
292
|
const a = extractArgs(tuple);
|
|
248
293
|
const params = a?.params;
|
|
249
294
|
const query = options?.queryOverride ?? a?.query;
|
|
250
|
-
const { url, normalizedQuery, normalizedParams } = buildUrl(
|
|
295
|
+
const { url, normalizedQuery, normalizedParams } = buildUrl(
|
|
296
|
+
{ ...leaf, cfg: leafCfg },
|
|
297
|
+
baseUrl,
|
|
298
|
+
params,
|
|
299
|
+
query
|
|
300
|
+
);
|
|
251
301
|
let payload;
|
|
252
|
-
const acceptsBody = Boolean(
|
|
302
|
+
const acceptsBody = Boolean(leafCfg.bodySchema);
|
|
253
303
|
const requiresBody = options?.requireBody ?? (!isGet && acceptsBody);
|
|
254
304
|
if (typeof options?.body !== "undefined") {
|
|
255
|
-
const normalizedBody = zParse(options.body,
|
|
256
|
-
const isMultipart = Array.isArray(
|
|
305
|
+
const normalizedBody = zParse(options.body, leafCfg.bodySchema);
|
|
306
|
+
const isMultipart = Array.isArray(leafCfg.bodyFiles) && leafCfg.bodyFiles.length > 0;
|
|
257
307
|
payload = isMultipart ? toFormData(normalizedBody) : normalizedBody;
|
|
258
308
|
} else if (requiresBody) {
|
|
259
309
|
throw new Error("Body is required when invoking a mutation fetch.");
|
|
@@ -277,7 +327,7 @@ function createRouteClient(opts) {
|
|
|
277
327
|
const out = await fetcher(
|
|
278
328
|
payload === void 0 ? { url, method } : { url, method, body: payload }
|
|
279
329
|
);
|
|
280
|
-
const parsed = zParse(out,
|
|
330
|
+
const parsed = zParse(out, leafCfg.outputSchema);
|
|
281
331
|
emit(
|
|
282
332
|
decorateDebugEvent(
|
|
283
333
|
{
|
|
@@ -313,7 +363,7 @@ function createRouteClient(opts) {
|
|
|
313
363
|
}
|
|
314
364
|
};
|
|
315
365
|
const fetchGet = (...tupleWithBody) => {
|
|
316
|
-
const acceptsBody = Boolean(
|
|
366
|
+
const acceptsBody = Boolean(leafCfg.bodySchema);
|
|
317
367
|
const tupleLength = tupleWithBody.length;
|
|
318
368
|
const maybeBodyIndex = expectsArgs ? 1 : 0;
|
|
319
369
|
const hasBodyCandidate = acceptsBody && tupleLength > maybeBodyIndex;
|
|
@@ -346,7 +396,12 @@ function createRouteClient(opts) {
|
|
|
346
396
|
},
|
|
347
397
|
[]
|
|
348
398
|
);
|
|
349
|
-
const { normalizedQuery, normalizedParams } = buildUrl(
|
|
399
|
+
const { normalizedQuery, normalizedParams } = buildUrl(
|
|
400
|
+
{ ...leaf, cfg: leafCfg },
|
|
401
|
+
baseUrl,
|
|
402
|
+
params,
|
|
403
|
+
query
|
|
404
|
+
);
|
|
350
405
|
const queryResult = (0, import_react_query.useInfiniteQuery)({
|
|
351
406
|
...buildOptions,
|
|
352
407
|
queryKey: getQueryKeys(...tuple),
|
|
@@ -400,7 +455,7 @@ function createRouteClient(opts) {
|
|
|
400
455
|
},
|
|
401
456
|
[]
|
|
402
457
|
);
|
|
403
|
-
buildUrl(leaf, baseUrl, params, query);
|
|
458
|
+
buildUrl({ ...leaf, cfg: leafCfg }, baseUrl, params, query);
|
|
404
459
|
const queryResult = (0, import_react_query.useQuery)({
|
|
405
460
|
...buildOptions,
|
|
406
461
|
queryKey: getQueryKeys(...tuple),
|
|
@@ -488,9 +543,9 @@ function toFormData(body) {
|
|
|
488
543
|
}
|
|
489
544
|
|
|
490
545
|
// src/sockets/socket.client.sys.ts
|
|
491
|
-
var
|
|
492
|
-
var roomValueSchema =
|
|
493
|
-
var buildRoomPayloadSchema = (metaSchema) =>
|
|
546
|
+
var import_zod2 = require("zod");
|
|
547
|
+
var roomValueSchema = import_zod2.z.union([import_zod2.z.array(import_zod2.z.string()), import_zod2.z.string()]);
|
|
548
|
+
var buildRoomPayloadSchema = (metaSchema) => import_zod2.z.object({
|
|
494
549
|
rooms: roomValueSchema,
|
|
495
550
|
meta: metaSchema
|
|
496
551
|
});
|