@emeryld/rrroutes-client 2.6.5 → 2.6.7

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,7 +1,7 @@
1
1
  <!--
2
2
  Summary:
3
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).
4
+ - Added new sections for router helpers, batched branches (`buildBranch`), infinite/feeds, FormData uploads, and socket utilities (SocketClient, provider hooks, socketed routes).
5
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
6
  -->
7
7
 
@@ -220,7 +220,53 @@ const listUsers = buildRoute('listUsers') // builds from routes.listUsers
220
220
  const updateUser = buildRoute('updateUser', {}, { name: 'profile' }) // debug name filtering
221
221
  ```
222
222
 
223
- ### 8) File uploads (FormData)
223
+ ### 8) Batch multiple built endpoints (`buildBranch`)
224
+
225
+ `buildBranch` lets you batch already-built endpoints behind one batch path.
226
+
227
+ ```ts
228
+ const getUser = client.build(registry.byKey['GET /v1/users/:userId'])
229
+ const updateUser = client.build(registry.byKey['PATCH /v1/users/:userId'])
230
+
231
+ const userBatch = client.buildBranch(
232
+ { getUser, updateUser },
233
+ { path: '/v1/batch', method: 'post' }, // method defaults to POST
234
+ )
235
+
236
+ const result = await userBatch.fetch({
237
+ getUser: {
238
+ args: { params: { userId: 'u_1' } },
239
+ },
240
+ updateUser: {
241
+ args: { params: { userId: 'u_1' } },
242
+ body: { name: 'Emery' }, // required for mutation leaves
243
+ },
244
+ })
245
+
246
+ // result keys map back to your aliases:
247
+ // { getUser: ..., updateUser: ... }
248
+ ```
249
+
250
+ The request body sent to `/v1/batch` is keyed by URL-encoded leaf keys:
251
+
252
+ ```ts
253
+ {
254
+ [encodeURIComponent('GET /v1/users/:userId')]: { params: { userId: 'u_1' } },
255
+ [encodeURIComponent('PATCH /v1/users/:userId')]: {
256
+ params: { userId: 'u_1' },
257
+ body: { name: 'Emery' },
258
+ },
259
+ }
260
+ ```
261
+
262
+ `buildBranch` also provides:
263
+
264
+ - `useEndpoint(input, options?)` for a single React Query hook over the batched response.
265
+ - `getQueryKeys(input?)` to derive per-alias keys.
266
+ - `invalidate(input?)` to invalidate each leaf alias exactly.
267
+ - `setData(input)` to write cache per alias.
268
+
269
+ ### 9) File uploads (FormData)
224
270
 
225
271
  If a leaf has `bodyFiles` set in its contract, the client automatically converts the body to `FormData`.
226
272
  For each declared file field name, pass files using `file${name}` in the input body.
@@ -251,7 +297,7 @@ await uploadAvatar.fetch(
251
297
  )
252
298
  ```
253
299
 
254
- ### 9) Debug logging
300
+ ### 10) Debug logging
255
301
 
256
302
  ```ts
257
303
  const client = createRouteClient({
package/dist/index.cjs CHANGED
@@ -44,6 +44,11 @@ __export(index_exports, {
44
44
  });
45
45
  module.exports = __toCommonJS(index_exports);
46
46
 
47
+ // src/routesV3.client.ts
48
+ var import_rrroutes_contract5 = require("@emeryld/rrroutes-contract");
49
+ var import_react_query4 = require("@tanstack/react-query");
50
+ var import_react4 = require("react");
51
+
47
52
  // src/routesV3.client.fetch.ts
48
53
  var HttpError = class extends Error {
49
54
  constructor({
@@ -1131,6 +1136,7 @@ function buildMutationLeaf(leaf, rqOpts, env) {
1131
1136
  }
1132
1137
 
1133
1138
  // src/routesV3.client.ts
1139
+ var BUILT_LEAF_META = "__rrroutesLeaf";
1134
1140
  var defaultDebugLogger = (event) => {
1135
1141
  if (typeof console === "undefined") return;
1136
1142
  const fn = console.debug ?? console.log;
@@ -1197,6 +1203,7 @@ function createRouteClient(opts) {
1197
1203
  const fetcher = opts.fetcher ?? defaultFetcher;
1198
1204
  const baseUrl = opts.baseUrl;
1199
1205
  const environment = opts.environment ?? void 0;
1206
+ const validateResponses = opts.validateResponses ?? true;
1200
1207
  const { emit: emitDebug, mode: debugMode } = createDebugEmitter(
1201
1208
  opts.debug,
1202
1209
  environment
@@ -1211,13 +1218,23 @@ function createRouteClient(opts) {
1211
1218
  await queryClient.invalidateQueries({ queryKey, exact });
1212
1219
  emitDebug({ type: "invalidate", key: queryKey, exact });
1213
1220
  }
1221
+ const toArgsTuple2 = (args) => typeof args === "undefined" ? [] : [args];
1222
+ const getBuiltLeaf = (built) => {
1223
+ const leaf = built[BUILT_LEAF_META];
1224
+ if (!leaf) {
1225
+ throw new Error(
1226
+ "buildBranch(...) expects endpoints created with this route client via client.build(...)."
1227
+ );
1228
+ }
1229
+ return leaf;
1230
+ };
1231
+ const encodeLeafKey = (leaf) => encodeURIComponent(`${leaf.method.toUpperCase()} ${leaf.path}`);
1214
1232
  function buildInternal(leaf, rqOpts, meta) {
1215
1233
  const leafLabel = `${leaf.method.toUpperCase()} ${String(leaf.path)}`;
1216
1234
  const debugName = meta?.name;
1217
1235
  const emit = (event) => emitDebug(event, debugName);
1218
1236
  const isGet = leaf.method === "get";
1219
1237
  const isFeed = !!leaf.cfg.feed;
1220
- const validateResponses = opts.validateResponses ?? true;
1221
1238
  const env = {
1222
1239
  baseUrl,
1223
1240
  validateResponses,
@@ -1228,25 +1245,33 @@ function createRouteClient(opts) {
1228
1245
  isVerboseDebug,
1229
1246
  leafLabel
1230
1247
  };
1248
+ let built;
1231
1249
  if (isGet && isFeed) {
1232
- return buildInfiniteGetLeaf(
1250
+ built = buildInfiniteGetLeaf(
1233
1251
  leaf,
1234
1252
  rqOpts,
1235
1253
  env
1236
1254
  );
1237
- }
1238
- if (isGet) {
1239
- return buildGetLeaf(
1255
+ } else if (isGet) {
1256
+ built = buildGetLeaf(
1257
+ leaf,
1258
+ rqOpts,
1259
+ env
1260
+ );
1261
+ } else {
1262
+ built = buildMutationLeaf(
1240
1263
  leaf,
1241
1264
  rqOpts,
1242
1265
  env
1243
1266
  );
1244
1267
  }
1245
- return buildMutationLeaf(
1246
- leaf,
1247
- rqOpts,
1248
- env
1249
- );
1268
+ Object.defineProperty(built, BUILT_LEAF_META, {
1269
+ value: leaf,
1270
+ enumerable: false,
1271
+ configurable: false,
1272
+ writable: false
1273
+ });
1274
+ return built;
1250
1275
  }
1251
1276
  const fetchRaw = async (input) => {
1252
1277
  const { path, method, query, body, params } = input;
@@ -1315,11 +1340,151 @@ function createRouteClient(opts) {
1315
1340
  throw error;
1316
1341
  }
1317
1342
  };
1343
+ const buildBranchInternal = (leaves, options) => {
1344
+ const batchMethod = options.method ?? "POST";
1345
+ const defaultHeaders = options.headers;
1346
+ const getQueryKeys = (input) => {
1347
+ const out = {};
1348
+ const argsByLeaf = input ?? {};
1349
+ for (const [alias, built] of Object.entries(leaves)) {
1350
+ const args = argsByLeaf[alias];
1351
+ out[alias] = built.getQueryKeys(...toArgsTuple2(args));
1352
+ }
1353
+ return out;
1354
+ };
1355
+ const invalidateBranch = async (input) => {
1356
+ const argsByLeaf = input ?? {};
1357
+ await Promise.all(
1358
+ Object.entries(leaves).map(([alias, built]) => {
1359
+ const args = argsByLeaf[alias];
1360
+ return built.invalidate(...toArgsTuple2(args));
1361
+ })
1362
+ );
1363
+ };
1364
+ const setDataBranch = (input) => {
1365
+ const out = {};
1366
+ for (const [alias, def] of Object.entries(input)) {
1367
+ if (!def) continue;
1368
+ const built = leaves[alias];
1369
+ if (!built) continue;
1370
+ out[alias] = built.setData(
1371
+ def.updater,
1372
+ ...toArgsTuple2(def.args)
1373
+ );
1374
+ }
1375
+ return out;
1376
+ };
1377
+ const fetchBranch = async (input) => {
1378
+ const payload = {};
1379
+ const keyByAlias = /* @__PURE__ */ new Map();
1380
+ for (const [aliasRaw, built] of Object.entries(leaves)) {
1381
+ const alias = aliasRaw;
1382
+ const leaf = getBuiltLeaf(built);
1383
+ const encodedLeaf = encodeLeafKey(leaf);
1384
+ keyByAlias.set(alias, encodedLeaf);
1385
+ const branchInput = input[alias];
1386
+ const args = branchInput?.args;
1387
+ const body = branchInput?.body;
1388
+ payload[encodedLeaf] = {
1389
+ ...args?.params !== void 0 ? { params: args.params } : {},
1390
+ ...args?.query !== void 0 ? { query: args.query } : {},
1391
+ ...body !== void 0 ? { body } : {}
1392
+ };
1393
+ }
1394
+ const batchResponse = await fetchRaw({
1395
+ path: options.path,
1396
+ method: batchMethod,
1397
+ body: payload,
1398
+ headers: defaultHeaders
1399
+ });
1400
+ const rawData = batchResponse.data;
1401
+ if (!rawData || typeof rawData !== "object" || Array.isArray(rawData)) {
1402
+ throw new Error(
1403
+ "Batch response must be a plain object keyed by encoded route keys."
1404
+ );
1405
+ }
1406
+ const mapped = {};
1407
+ for (const [aliasRaw, built] of Object.entries(leaves)) {
1408
+ const alias = aliasRaw;
1409
+ const encodedLeaf = keyByAlias.get(alias);
1410
+ if (!encodedLeaf) {
1411
+ throw new Error(
1412
+ `Internal batch error: missing encoded key for alias "${String(alias)}".`
1413
+ );
1414
+ }
1415
+ if (!(encodedLeaf in rawData)) {
1416
+ throw new Error(`Batch response missing key "${encodedLeaf}".`);
1417
+ }
1418
+ const leaf = getBuiltLeaf(built);
1419
+ const rawLeafData = rawData[encodedLeaf];
1420
+ const parsedLeafData = validateResponses && leaf.cfg.outputSchema ? (0, import_rrroutes_contract5.lowProfileParse)(leaf.cfg.outputSchema, rawLeafData) : rawLeafData;
1421
+ if (validateResponses && !leaf.cfg.outputSchema) {
1422
+ throw new Error(
1423
+ `No output schema defined for leaf ${leaf.method.toUpperCase()} ${leaf.path}, cannot validate batch response.`
1424
+ );
1425
+ }
1426
+ ;
1427
+ mapped[aliasRaw] = parsedLeafData;
1428
+ }
1429
+ return mapped;
1430
+ };
1431
+ const useEndpoint = (input, rqOpts) => {
1432
+ const queryKeys = getQueryKeys(input);
1433
+ const branchQueryKey = [
1434
+ "batch",
1435
+ String(batchMethod).toLowerCase(),
1436
+ options.path,
1437
+ queryKeys
1438
+ ];
1439
+ const { onReceive, ...useQueryOptions } = rqOpts ?? {};
1440
+ const listenersRef = (0, import_react4.useRef)(
1441
+ /* @__PURE__ */ new Set()
1442
+ );
1443
+ const notifyOnReceive = (0, import_react4.useCallback)(
1444
+ (data) => {
1445
+ onReceive?.(data);
1446
+ listenersRef.current.forEach((listener) => listener(data));
1447
+ },
1448
+ [onReceive]
1449
+ );
1450
+ const registerOnReceive = (0, import_react4.useCallback)(
1451
+ (listener) => {
1452
+ listenersRef.current.add(listener);
1453
+ return () => {
1454
+ listenersRef.current.delete(listener);
1455
+ };
1456
+ },
1457
+ []
1458
+ );
1459
+ const queryResult = (0, import_react_query4.useQuery)(
1460
+ {
1461
+ ...useQueryOptions,
1462
+ queryKey: branchQueryKey,
1463
+ placeholderData: useQueryOptions.placeholderData ?? import_react_query4.keepPreviousData,
1464
+ queryFn: async () => {
1465
+ const result = await fetchBranch(input);
1466
+ notifyOnReceive(result);
1467
+ return result;
1468
+ }
1469
+ },
1470
+ queryClient
1471
+ );
1472
+ return { ...queryResult, onReceive: registerOnReceive };
1473
+ };
1474
+ return {
1475
+ fetch: fetchBranch,
1476
+ useEndpoint,
1477
+ getQueryKeys,
1478
+ invalidate: invalidateBranch,
1479
+ setData: setDataBranch
1480
+ };
1481
+ };
1318
1482
  return {
1319
1483
  queryClient,
1320
1484
  invalidate,
1321
1485
  fetch: fetchRaw,
1322
- build: buildInternal
1486
+ build: buildInternal,
1487
+ buildBranch: buildBranchInternal
1323
1488
  };
1324
1489
  }
1325
1490
  function buildRouter(routeClient, routes) {
@@ -2163,26 +2328,48 @@ function useSocketConnection(args) {
2163
2328
  () => rooms == null ? [] : Array.isArray(rooms) ? rooms : [rooms],
2164
2329
  [rooms]
2165
2330
  );
2331
+ const reportAsyncError = React2.useCallback(
2332
+ (phase, error) => {
2333
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
2334
+ console.warn(`[socket] useSocketConnection ${phase} failed`, error);
2335
+ }
2336
+ },
2337
+ []
2338
+ );
2166
2339
  React2.useEffect(() => {
2167
2340
  if (autoJoin && normalizedRooms.length > 0)
2168
- client.joinRooms(normalizedRooms, args.joinMeta);
2341
+ void client.joinRooms(normalizedRooms, args.joinMeta).catch((error) => reportAsyncError("joinRooms", error));
2169
2342
  const unsubscribe = client.on(event, (payload, meta) => {
2170
2343
  onMessage(payload, meta);
2171
2344
  });
2172
2345
  return () => {
2173
2346
  unsubscribe();
2174
2347
  if (autoLeave && normalizedRooms.length > 0)
2175
- client.leaveRooms(normalizedRooms, args.leaveMeta);
2348
+ void client.leaveRooms(normalizedRooms, args.leaveMeta).catch((error) => reportAsyncError("leaveRooms", error));
2176
2349
  if (onCleanup) onCleanup();
2177
2350
  };
2178
- }, [client, event, onMessage, autoJoin, autoLeave, ...normalizedRooms]);
2351
+ }, [
2352
+ client,
2353
+ event,
2354
+ onMessage,
2355
+ autoJoin,
2356
+ autoLeave,
2357
+ reportAsyncError,
2358
+ ...normalizedRooms
2359
+ ]);
2179
2360
  }
2180
2361
 
2181
2362
  // src/sockets/socket.client.context.debug.ts
2182
2363
  function dbg(dbgOpts, e) {
2183
2364
  if (!dbgOpts?.logger) return;
2184
2365
  if (!dbgOpts[e.type]) return;
2185
- dbgOpts.logger(e);
2366
+ try {
2367
+ dbgOpts.logger(e);
2368
+ } catch (error) {
2369
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
2370
+ console.warn("[socket] provider debug logger threw", error);
2371
+ }
2372
+ }
2186
2373
  }
2187
2374
  function isProbablySocket(value) {
2188
2375
  if (!value || typeof value !== "object") return false;
@@ -2399,8 +2586,15 @@ function SocketProvider(props) {
2399
2586
  });
2400
2587
  return () => {
2401
2588
  if (client) {
2402
- client.destroy(destroyLeaveMetaRef.current);
2403
- dbg(providerDebugRef.current, { type: "client", phase: "destroy" });
2589
+ void Promise.resolve(client.destroy(destroyLeaveMetaRef.current)).then(() => {
2590
+ dbg(providerDebugRef.current, { type: "client", phase: "destroy" });
2591
+ }).catch((error) => {
2592
+ dbg(providerDebugRef.current, {
2593
+ type: "resolve",
2594
+ phase: "error",
2595
+ err: `client.destroy failed: ${String(error)}`
2596
+ });
2597
+ });
2404
2598
  }
2405
2599
  };
2406
2600
  }, [client]);
@@ -2409,7 +2603,7 @@ function SocketProvider(props) {
2409
2603
  }
2410
2604
 
2411
2605
  // src/sockets/socketedRoute/socket.client.helper.route.ts
2412
- var import_react4 = require("react");
2606
+ var import_react5 = require("react");
2413
2607
 
2414
2608
  // src/sockets/socketedRoute/socket.client.helper.debug.ts
2415
2609
  var objectReferenceIds = /* @__PURE__ */ new WeakMap();
@@ -2577,23 +2771,23 @@ function buildSocketedRoute(options) {
2577
2771
  const endpointResult = useInnerEndpoint(
2578
2772
  ...useArgs
2579
2773
  );
2580
- const argsKey = (0, import_react4.useMemo)(() => safeJsonKey(useArgs[0] ?? null), [useArgs]);
2581
- const [roomState, setRoomState] = (0, import_react4.useState)(
2774
+ const argsKey = (0, import_react5.useMemo)(() => safeJsonKey(useArgs[0] ?? null), [useArgs]);
2775
+ const [roomState, setRoomState] = (0, import_react5.useState)(
2582
2776
  () => roomsFromData(endpointResult.data, toRooms)
2583
2777
  );
2584
- const renderCountRef = (0, import_react4.useRef)(0);
2585
- const clientReadyRef = (0, import_react4.useRef)(null);
2586
- const onReceiveEffectDebugRef = (0, import_react4.useRef)(null);
2587
- const deriveRoomsEffectDebugRef = (0, import_react4.useRef)(null);
2588
- const joinRoomsEffectDebugRef = (0, import_react4.useRef)(null);
2589
- const applySocketEffectDebugRef = (0, import_react4.useRef)(null);
2778
+ const renderCountRef = (0, import_react5.useRef)(0);
2779
+ const clientReadyRef = (0, import_react5.useRef)(null);
2780
+ const onReceiveEffectDebugRef = (0, import_react5.useRef)(null);
2781
+ const deriveRoomsEffectDebugRef = (0, import_react5.useRef)(null);
2782
+ const joinRoomsEffectDebugRef = (0, import_react5.useRef)(null);
2783
+ const applySocketEffectDebugRef = (0, import_react5.useRef)(null);
2590
2784
  renderCountRef.current += 1;
2591
- const roomsKey = (0, import_react4.useMemo)(() => roomState.rooms.join("|"), [roomState.rooms]);
2592
- const joinMetaKey = (0, import_react4.useMemo)(
2785
+ const roomsKey = (0, import_react5.useMemo)(() => roomState.rooms.join("|"), [roomState.rooms]);
2786
+ const joinMetaKey = (0, import_react5.useMemo)(
2593
2787
  () => safeJsonKey(roomState.joinMeta ?? null),
2594
2788
  [roomState.joinMeta]
2595
2789
  );
2596
- const leaveMetaKey = (0, import_react4.useMemo)(
2790
+ const leaveMetaKey = (0, import_react5.useMemo)(
2597
2791
  () => safeJsonKey(roomState.leaveMeta ?? null),
2598
2792
  [roomState.leaveMeta]
2599
2793
  );
@@ -2616,7 +2810,7 @@ function buildSocketedRoute(options) {
2616
2810
  joinMetaKey,
2617
2811
  leaveMetaKey
2618
2812
  });
2619
- (0, import_react4.useEffect)(() => {
2813
+ (0, import_react5.useEffect)(() => {
2620
2814
  trackHookTrigger2({
2621
2815
  ref: onReceiveEffectDebugRef,
2622
2816
  phase: "endpoint_on_receive_effect",
@@ -2635,7 +2829,7 @@ function buildSocketedRoute(options) {
2635
2829
  });
2636
2830
  return unsubscribe;
2637
2831
  }, [endpointResult, toRooms, debug]);
2638
- (0, import_react4.useEffect)(() => {
2832
+ (0, import_react5.useEffect)(() => {
2639
2833
  trackHookTrigger2({
2640
2834
  ref: deriveRoomsEffectDebugRef,
2641
2835
  phase: "derive_rooms_effect",
@@ -2651,7 +2845,7 @@ function buildSocketedRoute(options) {
2651
2845
  );
2652
2846
  setRoomState((prev) => roomStateEqual(prev, next) ? prev : next);
2653
2847
  }, [endpointResult.data, toRooms, debug]);
2654
- (0, import_react4.useEffect)(() => {
2848
+ (0, import_react5.useEffect)(() => {
2655
2849
  trackHookTrigger2({
2656
2850
  ref: joinRoomsEffectDebugRef,
2657
2851
  phase: "join_rooms_effect",
@@ -2737,7 +2931,7 @@ function buildSocketedRoute(options) {
2737
2931
  });
2738
2932
  };
2739
2933
  }, [client, roomsKey, joinMetaKey, leaveMetaKey, debug]);
2740
- (0, import_react4.useEffect)(() => {
2934
+ (0, import_react5.useEffect)(() => {
2741
2935
  trackHookTrigger2({
2742
2936
  ref: applySocketEffectDebugRef,
2743
2937
  phase: "apply_socket_effect",