enlace 0.0.1-beta.2 → 0.0.1-beta.20

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
@@ -11,20 +11,26 @@ npm install enlace
11
11
  ## Quick Start
12
12
 
13
13
  ```typescript
14
- import { createEnlaceHook, Endpoint } from "enlace";
14
+ import { enlaceHookReact } from "enlace/hook";
15
+ import { Endpoint } from "enlace";
16
+
17
+ // Define your API error type
18
+ type ApiError = { message: string; code: number };
15
19
 
16
20
  type ApiSchema = {
17
21
  posts: {
18
- $get: Endpoint<Post[]>;
19
- $post: Endpoint<Post, ApiError, CreatePost>;
22
+ $get: Post[]; // Simple: just data type
23
+ $post: Endpoint<Post, CreatePost>; // Data + Body
24
+ $put: Endpoint<Post, UpdatePost, CustomError>; // Data + Body + Custom Error
20
25
  _: {
21
- $get: Endpoint<Post>;
22
- $delete: Endpoint<void>;
26
+ $get: Post; // Simple: just data type
27
+ $delete: void; // Simple: void response
23
28
  };
24
29
  };
25
30
  };
26
31
 
27
- const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com");
32
+ // Pass global error type as second generic
33
+ const useAPI = enlaceHookReact<ApiSchema, ApiError>("https://api.example.com");
28
34
  ```
29
35
 
30
36
  ## Schema Conventions
@@ -33,13 +39,13 @@ Defining a schema is **recommended** for full type safety, but **optional**. You
33
39
 
34
40
  ```typescript
35
41
  // Without schema (untyped, but still works!)
36
- const useAPI = createEnlaceHook("https://api.example.com");
37
- const { data } = useAPI((api) => api.any.path.you.want.get());
42
+ const useAPI = enlaceHookReact("https://api.example.com");
43
+ const { data } = useAPI((api) => api.any.path.you.want.$get());
38
44
  ```
39
45
 
40
46
  ```typescript
41
47
  // With schema (recommended for type safety)
42
- const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com");
48
+ const useAPI = enlaceHookReact<ApiSchema>("https://api.example.com");
43
49
  ```
44
50
 
45
51
  ### Schema Structure
@@ -50,40 +56,157 @@ const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com");
50
56
  ```typescript
51
57
  import { Endpoint } from "enlace";
52
58
 
59
+ type ApiError = { message: string };
60
+
53
61
  type ApiSchema = {
54
62
  users: {
55
- $get: Endpoint<User[]>; // GET /users
56
- $post: Endpoint<User>; // POST /users
57
- _: { // /users/:id
58
- $get: Endpoint<User>; // GET /users/:id
59
- $put: Endpoint<User>; // PUT /users/:id
60
- $delete: Endpoint<void>; // DELETE /users/:id
63
+ $get: User[]; // GET /users (simple)
64
+ $post: Endpoint<User, CreateUser>; // POST /users with body
65
+ _: {
66
+ // /users/:id
67
+ $get: User; // GET /users/:id (simple)
68
+ $put: Endpoint<User, UpdateUser>; // PUT /users/:id with body
69
+ $delete: void; // DELETE /users/:id (void response)
61
70
  profile: {
62
- $get: Endpoint<Profile>; // GET /users/:id/profile
71
+ $get: Profile; // GET /users/:id/profile (simple)
63
72
  };
64
73
  };
65
74
  };
66
75
  };
67
76
 
77
+ // Pass global error type - applies to all endpoints
78
+ const api = enlace<ApiSchema, ApiError>("https://api.example.com");
79
+
68
80
  // Usage
69
- api.users.get(); // GET /users
70
- api.users[123].get(); // GET /users/123
71
- api.users[123].profile.get(); // GET /users/123/profile
81
+ api.users.$get(); // GET /users
82
+ api.users[123].$get(); // GET /users/123
83
+ api.users[123].profile.$get(); // GET /users/123/profile
84
+ ```
85
+
86
+ ### Endpoint Types
87
+
88
+ The `Endpoint` type helpers let you define response data, request body, query params, formData, and error types.
89
+
90
+ #### `Endpoint<TData, TBody?, TError?>`
91
+
92
+ For endpoints with JSON body:
93
+
94
+ ```typescript
95
+ import { Endpoint } from "enlace";
96
+
97
+ type ApiSchema = {
98
+ posts: {
99
+ $get: Post[]; // Direct type (simplest)
100
+ $post: Endpoint<Post, CreatePost>; // Data + Body
101
+ $put: Endpoint<Post, UpdatePost, ValidationError>; // Data + Body + Error
102
+ $delete: void; // void response
103
+ $patch: Endpoint<Post, never, NotFoundError>; // Custom error without body
104
+ };
105
+ };
72
106
  ```
73
107
 
74
- ### Endpoint Type
108
+ #### `EndpointWithQuery<TData, TQuery, TError?>`
109
+
110
+ For endpoints with typed query parameters:
75
111
 
76
112
  ```typescript
77
- type Endpoint<TData, TError = unknown, TBody = never> = {
78
- data: TData; // Response data type
79
- error: TError; // Error response type
80
- body: TBody; // Request body type
113
+ import { EndpointWithQuery } from "enlace";
114
+
115
+ type ApiSchema = {
116
+ users: {
117
+ $get: EndpointWithQuery<
118
+ User[],
119
+ { page: number; limit: number; search?: string }
120
+ >;
121
+ };
122
+ posts: {
123
+ $get: EndpointWithQuery<
124
+ Post[],
125
+ { status: "draft" | "published" },
126
+ ApiError
127
+ >;
128
+ };
81
129
  };
82
130
 
83
- // Examples
84
- type GetUsers = Endpoint<User[]>; // GET, no body
85
- type CreateUser = Endpoint<User, ApiError, CreateUserInput>; // POST with body
86
- type DeleteUser = Endpoint<void, NotFoundError>; // DELETE, no response data
131
+ // Usage - query params are fully typed
132
+ const { data } = useAPI((api) =>
133
+ api.users.$get({ query: { page: 1, limit: 10 } })
134
+ );
135
+ // api.users.$get({ query: { foo: "bar" } }); // ✗ Error: 'foo' does not exist
136
+ ```
137
+
138
+ #### `EndpointWithFormData<TData, TFormData, TError?>`
139
+
140
+ For file uploads (multipart/form-data):
141
+
142
+ ```typescript
143
+ import { EndpointWithFormData } from "enlace";
144
+
145
+ type ApiSchema = {
146
+ uploads: {
147
+ $post: EndpointWithFormData<Upload, { file: Blob | File; name: string }>;
148
+ };
149
+ avatars: {
150
+ $post: EndpointWithFormData<Avatar, { image: File }, UploadError>;
151
+ };
152
+ };
153
+
154
+ // Usage - formData is automatically converted to FormData
155
+ const { trigger } = useAPI((api) => api.uploads.$post);
156
+ trigger({
157
+ formData: {
158
+ file: selectedFile, // File object
159
+ name: "document.pdf", // String - converted automatically
160
+ },
161
+ });
162
+ // → Sends as multipart/form-data
163
+ ```
164
+
165
+ **FormData conversion rules:**
166
+
167
+ | Type | Conversion |
168
+ | ------------------------------- | -------------------------------- |
169
+ | `File` / `Blob` | Appended directly |
170
+ | `string` / `number` / `boolean` | Converted to string |
171
+ | `object` (nested) | JSON stringified |
172
+ | `array` of primitives | Each item appended separately |
173
+ | `array` of files | Each file appended with same key |
174
+
175
+ #### `EndpointFull<T>`
176
+
177
+ Object-style for complex endpoints:
178
+
179
+ ```typescript
180
+ import { EndpointFull } from "enlace";
181
+
182
+ type ApiSchema = {
183
+ products: {
184
+ $post: EndpointFull<{
185
+ data: Product;
186
+ body: CreateProduct;
187
+ query: { categoryId: string };
188
+ error: ValidationError;
189
+ }>;
190
+ };
191
+ files: {
192
+ $post: EndpointFull<{
193
+ data: FileUpload;
194
+ formData: { file: File; description: string };
195
+ query: { folder: string };
196
+ }>;
197
+ };
198
+ };
199
+ ```
200
+
201
+ **Global error type:**
202
+
203
+ ```typescript
204
+ type ApiError = { message: string; code: number };
205
+
206
+ // Second generic sets default error type for all endpoints
207
+ const api = enlace<ApiSchema, ApiError>("https://api.example.com");
208
+ // const useAPI = enlaceHookReact<ApiSchema, ApiError>("...");
209
+ // const useAPI = enlaceHookNext<ApiSchema, ApiError>("...");
87
210
  ```
88
211
 
89
212
  ## React Hooks
@@ -94,12 +217,12 @@ For GET requests that fetch data automatically:
94
217
 
95
218
  ```typescript
96
219
  function Posts({ page, limit }: { page: number; limit: number }) {
97
- const { data, loading, error, ok } = useAPI((api) =>
98
- api.posts.get({ query: { page, limit, published: true } })
220
+ const { data, loading, error } = useAPI((api) =>
221
+ api.posts.$get({ query: { page, limit, published: true } })
99
222
  );
100
223
 
101
224
  if (loading) return <div>Loading...</div>;
102
- if (!ok) return <div>Error: {error.message}</div>;
225
+ if (error) return <div>Error: {error.message}</div>;
103
226
 
104
227
  return (
105
228
  <ul>
@@ -112,25 +235,152 @@ function Posts({ page, limit }: { page: number; limit: number }) {
112
235
  ```
113
236
 
114
237
  **Features:**
238
+
115
239
  - Auto-fetches on mount
116
240
  - Re-fetches when dependencies change (no deps array needed!)
117
241
  - Returns cached data while revalidating
242
+ - **Request deduplication** — identical requests from multiple components trigger only one fetch
243
+
244
+ ### Conditional Fetching
245
+
246
+ Skip fetching with the `enabled` option:
247
+
248
+ ```typescript
249
+ function ProductForm({ id }: { id: string | "new" }) {
250
+ // Skip fetching when creating a new product
251
+ const { data, loading } = useAPI(
252
+ (api) => api.products[id].$get(),
253
+ { enabled: id !== "new" }
254
+ );
255
+
256
+ if (id === "new") return <CreateProductForm />;
257
+ if (loading) return <div>Loading...</div>;
258
+ return <EditProductForm product={data} />;
259
+ }
260
+ ```
261
+
262
+ ```typescript
263
+ // Also useful when waiting for a dependency
264
+ function UserPosts({ userId }: { userId: string | undefined }) {
265
+ const { data } = useAPI((api) => api.users[userId!].posts.$get(), {
266
+ enabled: userId !== undefined,
267
+ });
268
+ }
269
+ ```
118
270
 
119
271
  ```typescript
120
272
  function Post({ id }: { id: number }) {
121
273
  // Automatically re-fetches when `id` or query values change
122
- const { data } = useAPI((api) => api.posts[id].get({ query: { include: "author" } }));
274
+ const { data } = useAPI((api) => api.posts[id].$get({ query: { include: "author" } }));
123
275
  return <div>{data?.title}</div>;
124
276
  }
125
277
  ```
126
278
 
279
+ ### Polling
280
+
281
+ Automatically refetch data at intervals using the `pollingInterval` option. Polling uses sequential timing — the interval starts counting **after** the previous request completes, preventing request pile-up:
282
+
283
+ ```typescript
284
+ function Notifications() {
285
+ const { data } = useAPI(
286
+ (api) => api.notifications.$get(),
287
+ { pollingInterval: 5000 } // Refetch every 5 seconds after previous request completes
288
+ );
289
+
290
+ return <NotificationList notifications={data} />;
291
+ }
292
+ ```
293
+
294
+ **Behavior:**
295
+
296
+ - Polling starts after the initial fetch completes
297
+ - Next poll is scheduled only after the current request finishes (success or error)
298
+ - Continues polling even on errors (retry behavior)
299
+ - Stops when component unmounts or `enabled` becomes `false`
300
+ - Resets when component remounts
301
+
302
+ **Dynamic polling with function:**
303
+
304
+ Use a function to conditionally poll based on the response data or error:
305
+
306
+ ```typescript
307
+ function OrderStatus({ orderId }: { orderId: string }) {
308
+ const { data } = useAPI(
309
+ (api) => api.orders[orderId].$get(),
310
+ {
311
+ // Poll every 2s while pending, stop when completed
312
+ pollingInterval: (order) => order?.status === "pending" ? 2000 : false,
313
+ }
314
+ );
315
+
316
+ return <div>Status: {data?.status}</div>;
317
+ }
318
+ ```
319
+
320
+ The function receives `(data, error)` and should return:
321
+
322
+ - `number`: Interval in milliseconds
323
+ - `false`: Stop polling
324
+
325
+ ```typescript
326
+ // Poll faster when there's an error (retry), slower otherwise
327
+ {
328
+ pollingInterval: (data, error) => (error ? 1000 : 10000);
329
+ }
330
+
331
+ // Stop polling once data meets a condition
332
+ {
333
+ pollingInterval: (order) => (order?.status === "completed" ? false : 3000);
334
+ }
335
+ ```
336
+
337
+ **Combined with conditional fetching:**
338
+
339
+ ```typescript
340
+ function OrderStatus({ orderId }: { orderId: string | undefined }) {
341
+ const { data } = useAPI((api) => api.orders[orderId!].$get(), {
342
+ enabled: !!orderId,
343
+ pollingInterval: 10000, // Poll every 10 seconds
344
+ });
345
+ // Polling only runs when orderId is defined
346
+ }
347
+ ```
348
+
349
+ ### Request Deduplication
350
+
351
+ Multiple components requesting the same data will share a single network request:
352
+
353
+ ```typescript
354
+ // Both components render at the same time
355
+ function PostTitle({ id }: { id: number }) {
356
+ const { data } = useAPI((api) => api.posts[id].$get());
357
+ return <h1>{data?.title}</h1>;
358
+ }
359
+
360
+ function PostBody({ id }: { id: number }) {
361
+ const { data } = useAPI((api) => api.posts[id].$get());
362
+ return <p>{data?.body}</p>;
363
+ }
364
+
365
+ // Only ONE fetch request is made to GET /posts/123
366
+ // Both components share the same cached result
367
+ function PostPage() {
368
+ return (
369
+ <>
370
+ <PostTitle id={123} />
371
+ <PostBody id={123} />
372
+ </>
373
+ );
374
+ }
375
+ ```
376
+
127
377
  ### Selector Mode (Manual Trigger)
128
378
 
129
379
  For mutations or lazy-loaded requests:
130
380
 
131
381
  ```typescript
132
382
  function DeleteButton({ id }: { id: number }) {
133
- const { trigger, loading } = useAPI((api) => api.posts[id].delete);
383
+ const { trigger, loading } = useAPI((api) => api.posts[id].$delete);
134
384
 
135
385
  return (
136
386
  <button onClick={() => trigger()} disabled={loading}>
@@ -144,11 +394,11 @@ function DeleteButton({ id }: { id: number }) {
144
394
 
145
395
  ```typescript
146
396
  function CreatePost() {
147
- const { trigger, loading, data } = useAPI((api) => api.posts.post);
397
+ const { trigger, loading, data } = useAPI((api) => api.posts.$post);
148
398
 
149
399
  const handleSubmit = async (title: string) => {
150
400
  const result = await trigger({ body: { title } });
151
- if (result.ok) {
401
+ if (!result.error) {
152
402
  console.log("Created:", result.data);
153
403
  }
154
404
  };
@@ -157,6 +407,58 @@ function CreatePost() {
157
407
  }
158
408
  ```
159
409
 
410
+ ### Dynamic Path Parameters
411
+
412
+ Use `:paramName` syntax for dynamic IDs passed at trigger time:
413
+
414
+ ```typescript
415
+ function PostList({ posts }: { posts: Post[] }) {
416
+ // Define once with :id placeholder
417
+ const { trigger, loading } = useAPI((api) => api.posts[":id"].$delete);
418
+
419
+ const handleDelete = (postId: number) => {
420
+ // Pass the actual ID when triggering
421
+ trigger({ params: { id: postId } });
422
+ };
423
+
424
+ return (
425
+ <ul>
426
+ {posts.map((post) => (
427
+ <li key={post.id}>
428
+ {post.title}
429
+ <button onClick={() => handleDelete(post.id)} disabled={loading}>
430
+ Delete
431
+ </button>
432
+ </li>
433
+ ))}
434
+ </ul>
435
+ );
436
+ }
437
+ ```
438
+
439
+ **Multiple path parameters:**
440
+
441
+ ```typescript
442
+ const { trigger } = useAPI(
443
+ (api) => api.users[":userId"].posts[":postId"].$delete
444
+ );
445
+
446
+ trigger({ params: { userId: "1", postId: "42" } });
447
+ // → DELETE /users/1/posts/42
448
+ ```
449
+
450
+ **With request body:**
451
+
452
+ ```typescript
453
+ const { trigger } = useAPI((api) => api.products[":id"].$patch);
454
+
455
+ trigger({
456
+ params: { id: "123" },
457
+ body: { name: "Updated Product" },
458
+ });
459
+ // → PATCH /products/123 with body
460
+ ```
461
+
160
462
  ## Caching & Auto-Revalidation
161
463
 
162
464
  ### Automatic Cache Tags (Zero Config)
@@ -172,7 +474,7 @@ function CreatePost() {
172
474
  **Mutations automatically revalidate matching tags:**
173
475
 
174
476
  ```typescript
175
- const { trigger } = useAPI((api) => api.posts.post);
477
+ const { trigger } = useAPI((api) => api.posts.$post);
176
478
 
177
479
  // POST /posts automatically revalidates 'posts' tag
178
480
  // All queries with 'posts' tag will refetch!
@@ -189,10 +491,10 @@ This means in most cases, **you don't need to specify any tags manually**. The c
189
491
 
190
492
  ```typescript
191
493
  // Component A: fetches posts (cached with tag 'posts')
192
- const { data } = useAPI((api) => api.posts.get());
494
+ const { data } = useAPI((api) => api.posts.$get());
193
495
 
194
496
  // Component B: creates a post
195
- const { trigger } = useAPI((api) => api.posts.post);
497
+ const { trigger } = useAPI((api) => api.posts.$post);
196
498
  trigger({ body: { title: "New" } });
197
499
  // → Automatically revalidates 'posts' tag
198
500
  // → Component A refetches automatically!
@@ -203,9 +505,13 @@ trigger({ body: { title: "New" } });
203
505
  Control how long cached data is considered fresh:
204
506
 
205
507
  ```typescript
206
- const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com", {}, {
207
- staleTime: 5000, // 5 seconds
208
- });
508
+ const useAPI = enlaceHookReact<ApiSchema>(
509
+ "https://api.example.com",
510
+ {},
511
+ {
512
+ staleTime: 5000, // 5 seconds
513
+ }
514
+ );
209
515
  ```
210
516
 
211
517
  - `staleTime: 0` (default) — Always revalidate on mount
@@ -217,29 +523,61 @@ const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com", {}, {
217
523
  Override auto-generated tags when needed:
218
524
 
219
525
  ```typescript
220
- // Custom cache tags
221
- const { data } = useAPI((api) => api.posts.get({ tags: ["my-custom-tag"] }));
526
+ // Custom cache tags (replaces auto-generated)
527
+ const { data } = useAPI((api) => api.posts.$get({ tags: ["my-custom-tag"] }));
222
528
 
223
- // Custom revalidation tags
529
+ // Custom revalidation tags (replaces auto-generated)
224
530
  trigger({
225
531
  body: { title: "New" },
226
- revalidateTags: ["posts", "dashboard"], // Override auto-generated
532
+ revalidateTags: ["posts", "dashboard"],
227
533
  });
228
534
  ```
229
535
 
230
- ### Disable Auto-Revalidation
536
+ ### Extending Auto-Generated Tags
537
+
538
+ Use `additionalTags` and `additionalRevalidateTags` to **merge** with auto-generated tags instead of replacing them:
231
539
 
232
540
  ```typescript
233
- const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com", {}, {
234
- autoGenerateTags: false, // Disable auto tag generation
235
- autoRevalidateTags: false, // Disable auto revalidation
541
+ // Extend cache tags (merges with auto-generated)
542
+ const { data } = useAPI((api) =>
543
+ api.posts.$get({ additionalTags: ["custom-tag"] })
544
+ );
545
+ // If autoGenerateTags produces ['posts'], final tags: ['posts', 'custom-tag']
546
+
547
+ // Extend revalidation tags (merges with auto-generated)
548
+ trigger({
549
+ body: { title: "New" },
550
+ additionalRevalidateTags: ["dashboard", "stats"],
236
551
  });
552
+ // If autoRevalidateTags produces ['posts'], final tags: ['posts', 'dashboard', 'stats']
553
+ ```
554
+
555
+ **Behavior:**
556
+
557
+ | Scenario | `tags` / `revalidateTags` | `additionalTags` / `additionalRevalidateTags` | Final Tags |
558
+ |----------|---------------------------|-----------------------------------------------|------------|
559
+ | Override | `['custom']` | - | `['custom']` |
560
+ | Extend auto | - | `['extra']` | `['posts', 'extra']` |
561
+ | Both | `['custom']` | `['extra']` | `['custom', 'extra']` |
562
+ | Neither | - | - | `['posts']` (auto) |
563
+
564
+ ### Disable Auto-Revalidation
565
+
566
+ ```typescript
567
+ const useAPI = enlaceHookReact<ApiSchema>(
568
+ "https://api.example.com",
569
+ {},
570
+ {
571
+ autoGenerateTags: false, // Disable auto tag generation
572
+ autoRevalidateTags: false, // Disable auto revalidation
573
+ }
574
+ );
237
575
  ```
238
576
 
239
577
  ## Hook Options
240
578
 
241
579
  ```typescript
242
- const useAPI = createEnlaceHook<ApiSchema>(
580
+ const useAPI = enlaceHookReact<ApiSchema>(
243
581
  "https://api.example.com",
244
582
  {
245
583
  // Default fetch options
@@ -247,22 +585,122 @@ const useAPI = createEnlaceHook<ApiSchema>(
247
585
  },
248
586
  {
249
587
  // Hook options
250
- autoGenerateTags: true, // Auto-generate cache tags from URL
251
- autoRevalidateTags: true, // Auto-revalidate after mutations
252
- staleTime: 0, // Cache freshness duration (ms)
588
+ autoGenerateTags: true, // Auto-generate cache tags from URL
589
+ autoRevalidateTags: true, // Auto-revalidate after mutations
590
+ staleTime: 0, // Cache freshness duration (ms)
253
591
  }
254
592
  );
255
593
  ```
256
594
 
595
+ ### Async Headers
596
+
597
+ Headers can be provided as a static value, sync function, or async function. This is useful when you need to fetch headers dynamically (e.g., auth tokens from async storage):
598
+
599
+ ```typescript
600
+ // Static headers
601
+ const useAPI = enlaceHookReact<ApiSchema>("https://api.example.com", {
602
+ headers: { Authorization: "Bearer token" },
603
+ });
604
+
605
+ // Sync function
606
+ const useAPI = enlaceHookReact<ApiSchema>("https://api.example.com", {
607
+ headers: () => ({ Authorization: `Bearer ${getToken()}` }),
608
+ });
609
+
610
+ // Async function
611
+ const useAPI = enlaceHookReact<ApiSchema>("https://api.example.com", {
612
+ headers: async () => {
613
+ const token = await getTokenFromStorage();
614
+ return { Authorization: `Bearer ${token}` };
615
+ },
616
+ });
617
+ ```
618
+
619
+ This also works for per-request headers:
620
+
621
+ ```typescript
622
+ const { data } = useAPI((api) =>
623
+ api.posts.$get({
624
+ headers: async () => {
625
+ const token = await refreshToken();
626
+ return { Authorization: `Bearer ${token}` };
627
+ },
628
+ })
629
+ );
630
+ ```
631
+
632
+ ### Global Callbacks
633
+
634
+ You can set up global `onSuccess` and `onError` callbacks that are called for every request:
635
+
636
+ ```typescript
637
+ const useAPI = enlaceHookReact<ApiSchema>(
638
+ "https://api.example.com",
639
+ {
640
+ headers: { Authorization: "Bearer token" },
641
+ },
642
+ {
643
+ onSuccess: (payload) => {
644
+ console.log("Request succeeded:", payload.status, payload.data);
645
+ },
646
+ onError: (payload) => {
647
+ if (payload.status === 0) {
648
+ // Network error
649
+ console.error("Network error:", payload.error.message);
650
+ } else {
651
+ // HTTP error (4xx, 5xx)
652
+ console.error("HTTP error:", payload.status, payload.error);
653
+ }
654
+ },
655
+ }
656
+ );
657
+ ```
658
+
659
+ **Callback Payloads:**
660
+
661
+ ```typescript
662
+ // onSuccess payload
663
+ type EnlaceCallbackPayload<T> = {
664
+ status: number;
665
+ data: T;
666
+ headers: Headers;
667
+ };
668
+
669
+ // onError payload (HTTP error or network error)
670
+ type EnlaceErrorCallbackPayload<T> =
671
+ | { status: number; error: T; headers: Headers } // HTTP error
672
+ | { status: 0; error: Error; headers: null }; // Network error
673
+ ```
674
+
675
+ **Use cases:**
676
+
677
+ - Global error logging/reporting
678
+ - Toast notifications for all API errors
679
+ - Authentication refresh on 401 errors
680
+ - Analytics tracking
681
+
257
682
  ## Return Types
258
683
 
259
684
  ### Query Mode
260
685
 
261
686
  ```typescript
687
+ // Basic usage
688
+ const result = useAPI((api) => api.posts.$get());
689
+
690
+ // With options
691
+ const result = useAPI((api) => api.posts.$get(), {
692
+ enabled: true, // Skip fetching when false
693
+ pollingInterval: 5000, // Refetch every 5s after previous request completes
694
+ });
695
+
696
+ // With dynamic polling
697
+ const result = useAPI((api) => api.orders[id].$get(), {
698
+ pollingInterval: (order) => (order?.status === "pending" ? 2000 : false),
699
+ });
700
+
262
701
  type UseEnlaceQueryResult<TData, TError> = {
263
- loading: boolean; // No cached data and fetching
264
- fetching: boolean; // Request in progress
265
- ok: boolean | undefined;
702
+ loading: boolean; // No cached data and fetching
703
+ fetching: boolean; // Request in progress
266
704
  data: TData | undefined;
267
705
  error: TError | undefined;
268
706
  };
@@ -272,32 +710,61 @@ type UseEnlaceQueryResult<TData, TError> = {
272
710
 
273
711
  ```typescript
274
712
  type UseEnlaceSelectorResult<TMethod> = {
275
- trigger: TMethod; // Function to trigger the request
713
+ trigger: TMethod; // Function to trigger the request
276
714
  loading: boolean;
277
715
  fetching: boolean;
278
- ok: boolean | undefined;
279
716
  data: TData | undefined;
280
717
  error: TError | undefined;
281
718
  };
282
719
  ```
283
720
 
721
+ ### Query Options
722
+
723
+ ```typescript
724
+ type UseEnlaceQueryOptions<TData, TError> = {
725
+ enabled?: boolean; // Skip fetching when false (default: true)
726
+ pollingInterval?: // Refetch interval after request completes
727
+ | number // Fixed interval in ms
728
+ | false // Disable polling
729
+ | ((data: TData | undefined, error: TError | undefined) => number | false); // Dynamic
730
+ };
731
+ ```
732
+
733
+ ### Request Options
734
+
735
+ ```typescript
736
+ type RequestOptions = {
737
+ query?: TQuery; // Query parameters (typed when using EndpointWithQuery/EndpointFull)
738
+ body?: TBody; // Request body (JSON)
739
+ formData?: TFormData; // FormData fields (auto-converted, for file uploads)
740
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>); // Request headers
741
+ tags?: string[]; // Cache tags - replaces auto-generated (GET only)
742
+ additionalTags?: string[]; // Cache tags - merges with auto-generated (GET only)
743
+ revalidateTags?: string[]; // Revalidation tags - replaces auto-generated
744
+ additionalRevalidateTags?: string[]; // Revalidation tags - merges with auto-generated
745
+ params?: Record<string, string | number>; // Dynamic path parameters
746
+ };
747
+ ```
748
+
284
749
  ---
285
750
 
286
751
  ## Next.js Integration
287
752
 
288
753
  ### Server Components
289
754
 
290
- Use `createEnlace` from `enlace/next` for server components:
755
+ Use `enlaceNext` from `enlace` for server components:
291
756
 
292
757
  ```typescript
293
- import { createEnlace } from "enlace/next";
758
+ import { enlaceNext } from "enlace";
294
759
 
295
- const api = createEnlace<ApiSchema>("https://api.example.com", {}, {
760
+ type ApiError = { message: string };
761
+
762
+ const api = enlaceNext<ApiSchema, ApiError>("https://api.example.com", {}, {
296
763
  autoGenerateTags: true,
297
764
  });
298
765
 
299
766
  export default async function Page() {
300
- const { data } = await api.posts.get({
767
+ const { data } = await api.posts.$get({
301
768
  revalidate: 60, // ISR: revalidate every 60 seconds
302
769
  });
303
770
 
@@ -307,14 +774,16 @@ export default async function Page() {
307
774
 
308
775
  ### Client Components
309
776
 
310
- Use `createEnlaceHook` from `enlace/next/hook` for client components:
777
+ Use `enlaceHookNext` from `enlace/hook` for client components:
311
778
 
312
779
  ```typescript
313
780
  "use client";
314
781
 
315
- import { createEnlaceHook } from "enlace/next/hook";
782
+ import { enlaceHookNext } from "enlace/hook";
783
+
784
+ type ApiError = { message: string };
316
785
 
317
- const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com");
786
+ const useAPI = enlaceHookNext<ApiSchema, ApiError>("https://api.example.com");
318
787
  ```
319
788
 
320
789
  ### Server-Side Revalidation
@@ -339,39 +808,78 @@ export async function revalidateAction(tags: string[], paths: string[]) {
339
808
 
340
809
  ```typescript
341
810
  // useAPI.ts
342
- import { createEnlaceHook } from "enlace/next/hook";
811
+ import { enlaceHookNext } from "enlace/hook";
343
812
  import { revalidateAction } from "./actions";
344
813
 
345
- const useAPI = createEnlaceHook<ApiSchema>("/api", {}, {
346
- revalidator: revalidateAction,
347
- });
814
+ type ApiError = { message: string };
815
+
816
+ const useAPI = enlaceHookNext<ApiSchema, ApiError>(
817
+ "/api",
818
+ {},
819
+ {
820
+ serverRevalidator: revalidateAction,
821
+ }
822
+ );
348
823
  ```
349
824
 
350
825
  **In components:**
351
826
 
352
827
  ```typescript
353
828
  function CreatePost() {
354
- const { trigger } = useAPI((api) => api.posts.post);
829
+ const { trigger } = useAPI((api) => api.posts.$post);
355
830
 
356
831
  const handleCreate = () => {
357
832
  trigger({
358
833
  body: { title: "New Post" },
359
- revalidateTags: ["posts"], // Passed to revalidator
360
- revalidatePaths: ["/posts"], // Passed to revalidator
834
+ revalidateTags: ["posts"], // Passed to serverRevalidator
835
+ revalidatePaths: ["/posts"], // Passed to serverRevalidator
361
836
  });
362
837
  };
363
838
  }
364
839
  ```
365
840
 
841
+ ### CSR-Heavy Projects
842
+
843
+ For projects that primarily use client-side rendering with minimal SSR, you can disable server-side revalidation by default:
844
+
845
+ ```typescript
846
+ const useAPI = enlaceHookNext<ApiSchema, ApiError>(
847
+ "/api",
848
+ {},
849
+ {
850
+ serverRevalidator: revalidateAction,
851
+ skipServerRevalidation: true, // Disable server revalidation by default
852
+ }
853
+ );
854
+
855
+ // Mutations won't trigger server revalidation by default
856
+ await trigger({ body: { title: "New Post" } });
857
+
858
+ // Opt-in to server revalidation when needed
859
+ await trigger({ body: { title: "New Post" }, serverRevalidate: true });
860
+ ```
861
+
862
+ ### Per-Request Server Revalidation Control
863
+
864
+ Override the global setting for individual requests:
865
+
866
+ ```typescript
867
+ // Skip server revalidation for this request
868
+ await trigger({ body: data, serverRevalidate: false });
869
+
870
+ // Force server revalidation for this request
871
+ await trigger({ body: data, serverRevalidate: true });
872
+ ```
873
+
366
874
  ### Next.js Request Options
367
875
 
368
876
  ```typescript
369
- api.posts.get({
370
- tags: ["posts"], // Next.js cache tags
371
- revalidate: 60, // ISR revalidation (seconds)
877
+ api.posts.$get({
878
+ tags: ["posts"], // Next.js cache tags
879
+ revalidate: 60, // ISR revalidation (seconds)
372
880
  revalidateTags: ["posts"], // Tags to invalidate after mutation
373
- revalidatePaths: ["/"], // Paths to revalidate after mutation
374
- skipRevalidator: false, // Skip server-side revalidation
881
+ revalidatePaths: ["/"], // Paths to revalidate after mutation
882
+ serverRevalidate: true, // Control server-side revalidation per-request
375
883
  });
376
884
  ```
377
885
 
@@ -381,37 +889,92 @@ Works with Next.js API routes:
381
889
 
382
890
  ```typescript
383
891
  // Client component calling /api/posts
384
- const useAPI = createEnlaceHook<ApiSchema>("/api");
892
+ const useAPI = enlaceHookNext<ApiSchema, ApiError>("/api");
385
893
  ```
386
894
 
387
895
  ---
388
896
 
389
897
  ## API Reference
390
898
 
391
- ### `createEnlaceHook<TSchema>(baseUrl, options?, hookOptions?)`
899
+ ### `enlaceHookReact<TSchema, TDefaultError>(baseUrl, options?, hookOptions?)`
392
900
 
393
901
  Creates a React hook for making API calls.
394
902
 
395
- **Parameters:**
903
+ ### `enlaceHookNext<TSchema, TDefaultError>(baseUrl, options?, hookOptions?)`
904
+
905
+ Creates a Next.js hook with server revalidation support.
906
+
907
+ ### `enlace<TSchema, TDefaultError>(baseUrl, options?, callbacks?)`
908
+
909
+ Creates a typed API client (non-hook, for direct calls or server components).
910
+
911
+ ### `enlaceNext<TSchema, TDefaultError>(baseUrl, options?, nextOptions?)`
912
+
913
+ Creates a Next.js typed API client with caching support.
914
+
915
+ **Generic Parameters:**
916
+
917
+ - `TSchema` — API schema type defining endpoints
918
+ - `TDefaultError` — Default error type for all endpoints (default: `unknown`)
919
+
920
+ **Function Parameters:**
921
+
396
922
  - `baseUrl` — Base URL for requests
397
923
  - `options` — Default fetch options (headers, cache, etc.)
398
- - `hookOptions` — Hook configuration
924
+ - `hookOptions` / `callbacks` / `nextOptions` Additional configuration
399
925
 
400
926
  **Hook Options:**
927
+
401
928
  ```typescript
402
929
  type EnlaceHookOptions = {
403
- autoGenerateTags?: boolean; // default: true
404
- autoRevalidateTags?: boolean; // default: true
405
- staleTime?: number; // default: 0
930
+ autoGenerateTags?: boolean; // default: true
931
+ autoRevalidateTags?: boolean; // default: true
932
+ staleTime?: number; // default: 0
933
+ onSuccess?: (payload: EnlaceCallbackPayload<unknown>) => void;
934
+ onError?: (payload: EnlaceErrorCallbackPayload<unknown>) => void;
406
935
  };
407
936
  ```
408
937
 
409
938
  ### Re-exports from enlace-core
410
939
 
411
- - `Endpoint` — Type helper for schema definition
940
+ - `Endpoint` — Type helper for endpoints with JSON body
941
+ - `EndpointWithQuery` — Type helper for endpoints with typed query params
942
+ - `EndpointWithFormData` — Type helper for file upload endpoints
943
+ - `EndpointFull` — Object-style type helper for complex endpoints
412
944
  - `EnlaceResponse` — Response type
413
945
  - `EnlaceOptions` — Fetch options type
414
946
 
947
+ ## OpenAPI Generation
948
+
949
+ Generate OpenAPI 3.0 specs from your TypeScript schema using [`enlace-openapi`](../openapi/README.md):
950
+
951
+ ```bash
952
+ npm install enlace-openapi
953
+ enlace-openapi --schema ./types/APISchema.ts --output ./openapi.json
954
+ ```
955
+
956
+ ## Framework Adapters
957
+
958
+ ### Hono
959
+
960
+ Use [`enlace-hono`](../hono/README.md) to automatically generate Enlace schemas from your Hono app:
961
+
962
+ ```typescript
963
+ import { Hono } from "hono";
964
+ import type { HonoToEnlace } from "enlace-hono";
965
+
966
+ const app = new Hono()
967
+ .basePath("/api")
968
+ .get("/posts", (c) => c.json([{ id: 1, title: "Hello" }]))
969
+ .get("/posts/:id", (c) => c.json({ id: c.req.param("id") }));
970
+
971
+ // Auto-generate schema from Hono types
972
+ type ApiSchema = HonoToEnlace<typeof app>;
973
+
974
+ // Use with Enlace
975
+ const client = enlace<ApiSchema["api"]>("http://localhost:3000/api");
976
+ ```
977
+
415
978
  ## License
416
979
 
417
980
  MIT