enlace 0.0.1-beta.2 → 0.0.1-beta.21

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
72
84
  ```
73
85
 
74
- ### Endpoint Type
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:
75
93
 
76
94
  ```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
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
+ };
81
105
  };
106
+ ```
107
+
108
+ #### `EndpointWithQuery<TData, TQuery, TError?>`
109
+
110
+ For endpoints with typed query parameters:
111
+
112
+ ```typescript
113
+ import { EndpointWithQuery } from "enlace";
82
114
 
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
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
+ };
129
+ };
130
+
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,81 @@ 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
+ ### Manual Tag Invalidation
565
+
566
+ Use `invalidateTags` to manually trigger cache invalidation and refetch queries:
567
+
568
+ ```typescript
569
+ import { invalidateTags } from "enlace/hook";
570
+
571
+ // Invalidate all queries tagged with 'posts'
572
+ invalidateTags(["posts"]);
573
+
574
+ // Invalidate multiple tags
575
+ invalidateTags(["posts", "users"]);
576
+ ```
577
+
578
+ This is useful when you need to refresh data outside of the normal mutation flow, such as:
579
+
580
+ - After receiving a WebSocket message
581
+ - After a background sync
582
+ - After external state changes
583
+
584
+ ### Disable Auto-Revalidation
585
+
586
+ ```typescript
587
+ const useAPI = enlaceHookReact<ApiSchema>(
588
+ "https://api.example.com",
589
+ {},
590
+ {
591
+ autoGenerateTags: false, // Disable auto tag generation
592
+ autoRevalidateTags: false, // Disable auto revalidation
593
+ }
594
+ );
237
595
  ```
238
596
 
239
597
  ## Hook Options
240
598
 
241
599
  ```typescript
242
- const useAPI = createEnlaceHook<ApiSchema>(
600
+ const useAPI = enlaceHookReact<ApiSchema>(
243
601
  "https://api.example.com",
244
602
  {
245
603
  // Default fetch options
@@ -247,22 +605,122 @@ const useAPI = createEnlaceHook<ApiSchema>(
247
605
  },
248
606
  {
249
607
  // 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)
608
+ autoGenerateTags: true, // Auto-generate cache tags from URL
609
+ autoRevalidateTags: true, // Auto-revalidate after mutations
610
+ staleTime: 0, // Cache freshness duration (ms)
611
+ }
612
+ );
613
+ ```
614
+
615
+ ### Async Headers
616
+
617
+ 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):
618
+
619
+ ```typescript
620
+ // Static headers
621
+ const useAPI = enlaceHookReact<ApiSchema>("https://api.example.com", {
622
+ headers: { Authorization: "Bearer token" },
623
+ });
624
+
625
+ // Sync function
626
+ const useAPI = enlaceHookReact<ApiSchema>("https://api.example.com", {
627
+ headers: () => ({ Authorization: `Bearer ${getToken()}` }),
628
+ });
629
+
630
+ // Async function
631
+ const useAPI = enlaceHookReact<ApiSchema>("https://api.example.com", {
632
+ headers: async () => {
633
+ const token = await getTokenFromStorage();
634
+ return { Authorization: `Bearer ${token}` };
635
+ },
636
+ });
637
+ ```
638
+
639
+ This also works for per-request headers:
640
+
641
+ ```typescript
642
+ const { data } = useAPI((api) =>
643
+ api.posts.$get({
644
+ headers: async () => {
645
+ const token = await refreshToken();
646
+ return { Authorization: `Bearer ${token}` };
647
+ },
648
+ })
649
+ );
650
+ ```
651
+
652
+ ### Global Callbacks
653
+
654
+ You can set up global `onSuccess` and `onError` callbacks that are called for every request:
655
+
656
+ ```typescript
657
+ const useAPI = enlaceHookReact<ApiSchema>(
658
+ "https://api.example.com",
659
+ {
660
+ headers: { Authorization: "Bearer token" },
661
+ },
662
+ {
663
+ onSuccess: (payload) => {
664
+ console.log("Request succeeded:", payload.status, payload.data);
665
+ },
666
+ onError: (payload) => {
667
+ if (payload.status === 0) {
668
+ // Network error
669
+ console.error("Network error:", payload.error.message);
670
+ } else {
671
+ // HTTP error (4xx, 5xx)
672
+ console.error("HTTP error:", payload.status, payload.error);
673
+ }
674
+ },
253
675
  }
254
676
  );
255
677
  ```
256
678
 
679
+ **Callback Payloads:**
680
+
681
+ ```typescript
682
+ // onSuccess payload
683
+ type EnlaceCallbackPayload<T> = {
684
+ status: number;
685
+ data: T;
686
+ headers: Headers;
687
+ };
688
+
689
+ // onError payload (HTTP error or network error)
690
+ type EnlaceErrorCallbackPayload<T> =
691
+ | { status: number; error: T; headers: Headers } // HTTP error
692
+ | { status: 0; error: Error; headers: null }; // Network error
693
+ ```
694
+
695
+ **Use cases:**
696
+
697
+ - Global error logging/reporting
698
+ - Toast notifications for all API errors
699
+ - Authentication refresh on 401 errors
700
+ - Analytics tracking
701
+
257
702
  ## Return Types
258
703
 
259
704
  ### Query Mode
260
705
 
261
706
  ```typescript
707
+ // Basic usage
708
+ const result = useAPI((api) => api.posts.$get());
709
+
710
+ // With options
711
+ const result = useAPI((api) => api.posts.$get(), {
712
+ enabled: true, // Skip fetching when false
713
+ pollingInterval: 5000, // Refetch every 5s after previous request completes
714
+ });
715
+
716
+ // With dynamic polling
717
+ const result = useAPI((api) => api.orders[id].$get(), {
718
+ pollingInterval: (order) => (order?.status === "pending" ? 2000 : false),
719
+ });
720
+
262
721
  type UseEnlaceQueryResult<TData, TError> = {
263
- loading: boolean; // No cached data and fetching
264
- fetching: boolean; // Request in progress
265
- ok: boolean | undefined;
722
+ loading: boolean; // No cached data and fetching
723
+ fetching: boolean; // Request in progress
266
724
  data: TData | undefined;
267
725
  error: TError | undefined;
268
726
  };
@@ -272,32 +730,61 @@ type UseEnlaceQueryResult<TData, TError> = {
272
730
 
273
731
  ```typescript
274
732
  type UseEnlaceSelectorResult<TMethod> = {
275
- trigger: TMethod; // Function to trigger the request
733
+ trigger: TMethod; // Function to trigger the request
276
734
  loading: boolean;
277
735
  fetching: boolean;
278
- ok: boolean | undefined;
279
736
  data: TData | undefined;
280
737
  error: TError | undefined;
281
738
  };
282
739
  ```
283
740
 
741
+ ### Query Options
742
+
743
+ ```typescript
744
+ type UseEnlaceQueryOptions<TData, TError> = {
745
+ enabled?: boolean; // Skip fetching when false (default: true)
746
+ pollingInterval?: // Refetch interval after request completes
747
+ | number // Fixed interval in ms
748
+ | false // Disable polling
749
+ | ((data: TData | undefined, error: TError | undefined) => number | false); // Dynamic
750
+ };
751
+ ```
752
+
753
+ ### Request Options
754
+
755
+ ```typescript
756
+ type RequestOptions = {
757
+ query?: TQuery; // Query parameters (typed when using EndpointWithQuery/EndpointFull)
758
+ body?: TBody; // Request body (JSON)
759
+ formData?: TFormData; // FormData fields (auto-converted, for file uploads)
760
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>); // Request headers
761
+ tags?: string[]; // Cache tags - replaces auto-generated (GET only)
762
+ additionalTags?: string[]; // Cache tags - merges with auto-generated (GET only)
763
+ revalidateTags?: string[]; // Revalidation tags - replaces auto-generated
764
+ additionalRevalidateTags?: string[]; // Revalidation tags - merges with auto-generated
765
+ params?: Record<string, string | number>; // Dynamic path parameters
766
+ };
767
+ ```
768
+
284
769
  ---
285
770
 
286
771
  ## Next.js Integration
287
772
 
288
773
  ### Server Components
289
774
 
290
- Use `createEnlace` from `enlace/next` for server components:
775
+ Use `enlaceNext` from `enlace` for server components:
291
776
 
292
777
  ```typescript
293
- import { createEnlace } from "enlace/next";
778
+ import { enlaceNext } from "enlace";
779
+
780
+ type ApiError = { message: string };
294
781
 
295
- const api = createEnlace<ApiSchema>("https://api.example.com", {}, {
782
+ const api = enlaceNext<ApiSchema, ApiError>("https://api.example.com", {}, {
296
783
  autoGenerateTags: true,
297
784
  });
298
785
 
299
786
  export default async function Page() {
300
- const { data } = await api.posts.get({
787
+ const { data } = await api.posts.$get({
301
788
  revalidate: 60, // ISR: revalidate every 60 seconds
302
789
  });
303
790
 
@@ -307,14 +794,16 @@ export default async function Page() {
307
794
 
308
795
  ### Client Components
309
796
 
310
- Use `createEnlaceHook` from `enlace/next/hook` for client components:
797
+ Use `enlaceHookNext` from `enlace/hook` for client components:
311
798
 
312
799
  ```typescript
313
800
  "use client";
314
801
 
315
- import { createEnlaceHook } from "enlace/next/hook";
802
+ import { enlaceHookNext } from "enlace/hook";
316
803
 
317
- const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com");
804
+ type ApiError = { message: string };
805
+
806
+ const useAPI = enlaceHookNext<ApiSchema, ApiError>("https://api.example.com");
318
807
  ```
319
808
 
320
809
  ### Server-Side Revalidation
@@ -339,39 +828,78 @@ export async function revalidateAction(tags: string[], paths: string[]) {
339
828
 
340
829
  ```typescript
341
830
  // useAPI.ts
342
- import { createEnlaceHook } from "enlace/next/hook";
831
+ import { enlaceHookNext } from "enlace/hook";
343
832
  import { revalidateAction } from "./actions";
344
833
 
345
- const useAPI = createEnlaceHook<ApiSchema>("/api", {}, {
346
- revalidator: revalidateAction,
347
- });
834
+ type ApiError = { message: string };
835
+
836
+ const useAPI = enlaceHookNext<ApiSchema, ApiError>(
837
+ "/api",
838
+ {},
839
+ {
840
+ serverRevalidator: revalidateAction,
841
+ }
842
+ );
348
843
  ```
349
844
 
350
845
  **In components:**
351
846
 
352
847
  ```typescript
353
848
  function CreatePost() {
354
- const { trigger } = useAPI((api) => api.posts.post);
849
+ const { trigger } = useAPI((api) => api.posts.$post);
355
850
 
356
851
  const handleCreate = () => {
357
852
  trigger({
358
853
  body: { title: "New Post" },
359
- revalidateTags: ["posts"], // Passed to revalidator
360
- revalidatePaths: ["/posts"], // Passed to revalidator
854
+ revalidateTags: ["posts"], // Passed to serverRevalidator
855
+ revalidatePaths: ["/posts"], // Passed to serverRevalidator
361
856
  });
362
857
  };
363
858
  }
364
859
  ```
365
860
 
861
+ ### CSR-Heavy Projects
862
+
863
+ For projects that primarily use client-side rendering with minimal SSR, you can disable server-side revalidation by default:
864
+
865
+ ```typescript
866
+ const useAPI = enlaceHookNext<ApiSchema, ApiError>(
867
+ "/api",
868
+ {},
869
+ {
870
+ serverRevalidator: revalidateAction,
871
+ skipServerRevalidation: true, // Disable server revalidation by default
872
+ }
873
+ );
874
+
875
+ // Mutations won't trigger server revalidation by default
876
+ await trigger({ body: { title: "New Post" } });
877
+
878
+ // Opt-in to server revalidation when needed
879
+ await trigger({ body: { title: "New Post" }, serverRevalidate: true });
880
+ ```
881
+
882
+ ### Per-Request Server Revalidation Control
883
+
884
+ Override the global setting for individual requests:
885
+
886
+ ```typescript
887
+ // Skip server revalidation for this request
888
+ await trigger({ body: data, serverRevalidate: false });
889
+
890
+ // Force server revalidation for this request
891
+ await trigger({ body: data, serverRevalidate: true });
892
+ ```
893
+
366
894
  ### Next.js Request Options
367
895
 
368
896
  ```typescript
369
- api.posts.get({
370
- tags: ["posts"], // Next.js cache tags
371
- revalidate: 60, // ISR revalidation (seconds)
897
+ api.posts.$get({
898
+ tags: ["posts"], // Next.js cache tags
899
+ revalidate: 60, // ISR revalidation (seconds)
372
900
  revalidateTags: ["posts"], // Tags to invalidate after mutation
373
- revalidatePaths: ["/"], // Paths to revalidate after mutation
374
- skipRevalidator: false, // Skip server-side revalidation
901
+ revalidatePaths: ["/"], // Paths to revalidate after mutation
902
+ serverRevalidate: true, // Control server-side revalidation per-request
375
903
  });
376
904
  ```
377
905
 
@@ -381,37 +909,92 @@ Works with Next.js API routes:
381
909
 
382
910
  ```typescript
383
911
  // Client component calling /api/posts
384
- const useAPI = createEnlaceHook<ApiSchema>("/api");
912
+ const useAPI = enlaceHookNext<ApiSchema, ApiError>("/api");
385
913
  ```
386
914
 
387
915
  ---
388
916
 
389
917
  ## API Reference
390
918
 
391
- ### `createEnlaceHook<TSchema>(baseUrl, options?, hookOptions?)`
919
+ ### `enlaceHookReact<TSchema, TDefaultError>(baseUrl, options?, hookOptions?)`
392
920
 
393
921
  Creates a React hook for making API calls.
394
922
 
395
- **Parameters:**
923
+ ### `enlaceHookNext<TSchema, TDefaultError>(baseUrl, options?, hookOptions?)`
924
+
925
+ Creates a Next.js hook with server revalidation support.
926
+
927
+ ### `enlace<TSchema, TDefaultError>(baseUrl, options?, callbacks?)`
928
+
929
+ Creates a typed API client (non-hook, for direct calls or server components).
930
+
931
+ ### `enlaceNext<TSchema, TDefaultError>(baseUrl, options?, nextOptions?)`
932
+
933
+ Creates a Next.js typed API client with caching support.
934
+
935
+ **Generic Parameters:**
936
+
937
+ - `TSchema` — API schema type defining endpoints
938
+ - `TDefaultError` — Default error type for all endpoints (default: `unknown`)
939
+
940
+ **Function Parameters:**
941
+
396
942
  - `baseUrl` — Base URL for requests
397
943
  - `options` — Default fetch options (headers, cache, etc.)
398
- - `hookOptions` — Hook configuration
944
+ - `hookOptions` / `callbacks` / `nextOptions` Additional configuration
399
945
 
400
946
  **Hook Options:**
947
+
401
948
  ```typescript
402
949
  type EnlaceHookOptions = {
403
- autoGenerateTags?: boolean; // default: true
404
- autoRevalidateTags?: boolean; // default: true
405
- staleTime?: number; // default: 0
950
+ autoGenerateTags?: boolean; // default: true
951
+ autoRevalidateTags?: boolean; // default: true
952
+ staleTime?: number; // default: 0
953
+ onSuccess?: (payload: EnlaceCallbackPayload<unknown>) => void;
954
+ onError?: (payload: EnlaceErrorCallbackPayload<unknown>) => void;
406
955
  };
407
956
  ```
408
957
 
409
958
  ### Re-exports from enlace-core
410
959
 
411
- - `Endpoint` — Type helper for schema definition
960
+ - `Endpoint` — Type helper for endpoints with JSON body
961
+ - `EndpointWithQuery` — Type helper for endpoints with typed query params
962
+ - `EndpointWithFormData` — Type helper for file upload endpoints
963
+ - `EndpointFull` — Object-style type helper for complex endpoints
412
964
  - `EnlaceResponse` — Response type
413
965
  - `EnlaceOptions` — Fetch options type
414
966
 
967
+ ## OpenAPI Generation
968
+
969
+ Generate OpenAPI 3.0 specs from your TypeScript schema using [`enlace-openapi`](../openapi/README.md):
970
+
971
+ ```bash
972
+ npm install enlace-openapi
973
+ enlace-openapi --schema ./types/APISchema.ts --output ./openapi.json
974
+ ```
975
+
976
+ ## Framework Adapters
977
+
978
+ ### Hono
979
+
980
+ Use [`enlace-hono`](../hono/README.md) to automatically generate Enlace schemas from your Hono app:
981
+
982
+ ```typescript
983
+ import { Hono } from "hono";
984
+ import type { HonoToEnlace } from "enlace-hono";
985
+
986
+ const app = new Hono()
987
+ .basePath("/api")
988
+ .get("/posts", (c) => c.json([{ id: 1, title: "Hello" }]))
989
+ .get("/posts/:id", (c) => c.json({ id: c.req.param("id") }));
990
+
991
+ // Auto-generate schema from Hono types
992
+ type ApiSchema = HonoToEnlace<typeof app>;
993
+
994
+ // Use with Enlace
995
+ const client = enlace<ApiSchema["api"]>("http://localhost:3000/api");
996
+ ```
997
+
415
998
  ## License
416
999
 
417
1000
  MIT