enlace 0.0.1-beta.1 → 0.0.1-beta.10

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 ADDED
@@ -0,0 +1,728 @@
1
+ # enlace
2
+
3
+ Type-safe API client with React hooks and Next.js integration.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install enlace
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { createEnlaceHookReact } from "enlace/hook";
15
+ import { Endpoint } from "enlace";
16
+
17
+ // Define your API error type
18
+ type ApiError = { message: string; code: number };
19
+
20
+ type ApiSchema = {
21
+ posts: {
22
+ $get: Post[]; // Simple: just data type
23
+ $post: Endpoint<Post, CreatePost>; // Data + Body
24
+ $put: Endpoint<Post, UpdatePost, CustomError>; // Data + Body + Custom Error
25
+ _: {
26
+ $get: Post; // Simple: just data type
27
+ $delete: void; // Simple: void response
28
+ };
29
+ };
30
+ };
31
+
32
+ // Pass global error type as second generic
33
+ const useAPI = createEnlaceHookReact<ApiSchema, ApiError>(
34
+ "https://api.example.com"
35
+ );
36
+ ```
37
+
38
+ ## Schema Conventions
39
+
40
+ Defining a schema is **recommended** for full type safety, but **optional**. You can go without types:
41
+
42
+ ```typescript
43
+ // Without schema (untyped, but still works!)
44
+ const useAPI = createEnlaceHookReact("https://api.example.com");
45
+ const { data } = useAPI((api) => api.any.path.you.want.get());
46
+ ```
47
+
48
+ ```typescript
49
+ // With schema (recommended for type safety)
50
+ const useAPI = createEnlaceHookReact<ApiSchema>("https://api.example.com");
51
+ ```
52
+
53
+ ### Schema Structure
54
+
55
+ - `$get`, `$post`, `$put`, `$patch`, `$delete` — HTTP method endpoints
56
+ - `_` — Dynamic path segment (e.g., `/users/:id`)
57
+
58
+ ```typescript
59
+ import { Endpoint } from "enlace";
60
+
61
+ type ApiError = { message: string };
62
+
63
+ type ApiSchema = {
64
+ users: {
65
+ $get: User[]; // GET /users (simple)
66
+ $post: Endpoint<User, CreateUser>; // POST /users with body
67
+ _: {
68
+ // /users/:id
69
+ $get: User; // GET /users/:id (simple)
70
+ $put: Endpoint<User, UpdateUser>; // PUT /users/:id with body
71
+ $delete: void; // DELETE /users/:id (void response)
72
+ profile: {
73
+ $get: Profile; // GET /users/:id/profile (simple)
74
+ };
75
+ };
76
+ };
77
+ };
78
+
79
+ // Pass global error type - applies to all endpoints
80
+ const api = createEnlace<ApiSchema, ApiError>("https://api.example.com");
81
+
82
+ // Usage
83
+ api.users.get(); // GET /users
84
+ api.users[123].get(); // GET /users/123
85
+ api.users[123].profile.get(); // GET /users/123/profile
86
+ ```
87
+
88
+ ### Endpoint Type
89
+
90
+ The `Endpoint` type helper lets you define response data, request body, and optionally override the error type:
91
+
92
+ ```typescript
93
+ // Signature: Endpoint<TData, TBody?, TError?>
94
+ type Endpoint<TData, TBody = never, TError = never>;
95
+ ```
96
+
97
+ **Three ways to define endpoints:**
98
+
99
+ ```typescript
100
+ type ApiSchema = {
101
+ posts: {
102
+ // 1. Direct type (simplest) - just the data type
103
+ // Error comes from global default
104
+ $get: Post[];
105
+
106
+ // 2. Endpoint with body - Endpoint<Data, Body>
107
+ // Error comes from global default
108
+ $post: Endpoint<Post, CreatePost>;
109
+
110
+ // 3. Endpoint with custom error - Endpoint<Data, Body, Error>
111
+ // Overrides global error type for this endpoint
112
+ $put: Endpoint<Post, UpdatePost, ValidationError>;
113
+
114
+ // void response - use void directly
115
+ $delete: void;
116
+
117
+ // Custom error without body - use `never` for body
118
+ $patch: Endpoint<Post, never, NotFoundError>;
119
+ };
120
+ };
121
+ ```
122
+
123
+ **Global error type:**
124
+
125
+ ```typescript
126
+ type ApiError = { message: string; code: number };
127
+
128
+ // Second generic sets default error type for all endpoints
129
+ const api = createEnlace<ApiSchema, ApiError>("https://api.example.com");
130
+ // const useAPI = createEnlaceHookReact<ApiSchema, ApiError>("...");
131
+ // const useAPI = createEnlaceHookNext<ApiSchema, ApiError>("...");
132
+ ```
133
+
134
+ ## React Hooks
135
+
136
+ ### Query Mode (Auto-Fetch)
137
+
138
+ For GET requests that fetch data automatically:
139
+
140
+ ```typescript
141
+ function Posts({ page, limit }: { page: number; limit: number }) {
142
+ const { data, loading, error } = useAPI((api) =>
143
+ api.posts.get({ query: { page, limit, published: true } })
144
+ );
145
+
146
+ if (loading) return <div>Loading...</div>;
147
+ if (error) return <div>Error: {error.message}</div>;
148
+
149
+ return (
150
+ <ul>
151
+ {data.map((post) => (
152
+ <li key={post.id}>{post.title}</li>
153
+ ))}
154
+ </ul>
155
+ );
156
+ }
157
+ ```
158
+
159
+ **Features:**
160
+
161
+ - Auto-fetches on mount
162
+ - Re-fetches when dependencies change (no deps array needed!)
163
+ - Returns cached data while revalidating
164
+ - **Request deduplication** — identical requests from multiple components trigger only one fetch
165
+
166
+ ### Conditional Fetching
167
+
168
+ Skip fetching with the `enabled` option:
169
+
170
+ ```typescript
171
+ function ProductForm({ id }: { id: string | "new" }) {
172
+ // Skip fetching when creating a new product
173
+ const { data, loading } = useAPI(
174
+ (api) => api.products[id].get(),
175
+ { enabled: id !== "new" }
176
+ );
177
+
178
+ if (id === "new") return <CreateProductForm />;
179
+ if (loading) return <div>Loading...</div>;
180
+ return <EditProductForm product={data} />;
181
+ }
182
+ ```
183
+
184
+ ```typescript
185
+ // Also useful when waiting for a dependency
186
+ function UserPosts({ userId }: { userId: string | undefined }) {
187
+ const { data } = useAPI((api) => api.users[userId!].posts.get(), {
188
+ enabled: userId !== undefined,
189
+ });
190
+ }
191
+ ```
192
+
193
+ ```typescript
194
+ function Post({ id }: { id: number }) {
195
+ // Automatically re-fetches when `id` or query values change
196
+ const { data } = useAPI((api) => api.posts[id].get({ query: { include: "author" } }));
197
+ return <div>{data?.title}</div>;
198
+ }
199
+ ```
200
+
201
+ ### Request Deduplication
202
+
203
+ Multiple components requesting the same data will share a single network request:
204
+
205
+ ```typescript
206
+ // Both components render at the same time
207
+ function PostTitle({ id }: { id: number }) {
208
+ const { data } = useAPI((api) => api.posts[id].get());
209
+ return <h1>{data?.title}</h1>;
210
+ }
211
+
212
+ function PostBody({ id }: { id: number }) {
213
+ const { data } = useAPI((api) => api.posts[id].get());
214
+ return <p>{data?.body}</p>;
215
+ }
216
+
217
+ // Only ONE fetch request is made to GET /posts/123
218
+ // Both components share the same cached result
219
+ function PostPage() {
220
+ return (
221
+ <>
222
+ <PostTitle id={123} />
223
+ <PostBody id={123} />
224
+ </>
225
+ );
226
+ }
227
+ ```
228
+
229
+ ### Selector Mode (Manual Trigger)
230
+
231
+ For mutations or lazy-loaded requests:
232
+
233
+ ```typescript
234
+ function DeleteButton({ id }: { id: number }) {
235
+ const { trigger, loading } = useAPI((api) => api.posts[id].delete);
236
+
237
+ return (
238
+ <button onClick={() => trigger()} disabled={loading}>
239
+ {loading ? "Deleting..." : "Delete"}
240
+ </button>
241
+ );
242
+ }
243
+ ```
244
+
245
+ **With request body:**
246
+
247
+ ```typescript
248
+ function CreatePost() {
249
+ const { trigger, loading, data } = useAPI((api) => api.posts.post);
250
+
251
+ const handleSubmit = async (title: string) => {
252
+ const result = await trigger({ body: { title } });
253
+ if (!result.error) {
254
+ console.log("Created:", result.data);
255
+ }
256
+ };
257
+
258
+ return <button onClick={() => handleSubmit("New Post")}>Create</button>;
259
+ }
260
+ ```
261
+
262
+ ### Dynamic Path Parameters
263
+
264
+ Use `:paramName` syntax for dynamic IDs passed at trigger time:
265
+
266
+ ```typescript
267
+ function PostList({ posts }: { posts: Post[] }) {
268
+ // Define once with :id placeholder
269
+ const { trigger, loading } = useAPI((api) => api.posts[":id"].delete);
270
+
271
+ const handleDelete = (postId: number) => {
272
+ // Pass the actual ID when triggering
273
+ trigger({ pathParams: { id: postId } });
274
+ };
275
+
276
+ return (
277
+ <ul>
278
+ {posts.map((post) => (
279
+ <li key={post.id}>
280
+ {post.title}
281
+ <button onClick={() => handleDelete(post.id)} disabled={loading}>
282
+ Delete
283
+ </button>
284
+ </li>
285
+ ))}
286
+ </ul>
287
+ );
288
+ }
289
+ ```
290
+
291
+ **Multiple path parameters:**
292
+
293
+ ```typescript
294
+ const { trigger } = useAPI(
295
+ (api) => api.users[":userId"].posts[":postId"].delete
296
+ );
297
+
298
+ trigger({ pathParams: { userId: "1", postId: "42" } });
299
+ // → DELETE /users/1/posts/42
300
+ ```
301
+
302
+ **With request body:**
303
+
304
+ ```typescript
305
+ const { trigger } = useAPI((api) => api.products[":id"].patch);
306
+
307
+ trigger({
308
+ pathParams: { id: "123" },
309
+ body: { name: "Updated Product" },
310
+ });
311
+ // → PATCH /products/123 with body
312
+ ```
313
+
314
+ ## Caching & Auto-Revalidation
315
+
316
+ ### Automatic Cache Tags (Zero Config)
317
+
318
+ **Tags are automatically generated from URL paths** — no manual configuration needed:
319
+
320
+ ```typescript
321
+ // GET /posts → tags: ['posts']
322
+ // GET /posts/123 → tags: ['posts', 'posts/123']
323
+ // GET /users/5/posts → tags: ['users', 'users/5', 'users/5/posts']
324
+ ```
325
+
326
+ **Mutations automatically revalidate matching tags:**
327
+
328
+ ```typescript
329
+ const { trigger } = useAPI((api) => api.posts.post);
330
+
331
+ // POST /posts automatically revalidates 'posts' tag
332
+ // All queries with 'posts' tag will refetch!
333
+ trigger({ body: { title: "New Post" } });
334
+ ```
335
+
336
+ This means in most cases, **you don't need to specify any tags manually**. The cache just works.
337
+
338
+ ### How It Works
339
+
340
+ 1. **Queries** automatically cache with tags derived from the URL
341
+ 2. **Mutations** automatically revalidate tags derived from the URL
342
+ 3. All queries matching those tags refetch automatically
343
+
344
+ ```typescript
345
+ // Component A: fetches posts (cached with tag 'posts')
346
+ const { data } = useAPI((api) => api.posts.get());
347
+
348
+ // Component B: creates a post
349
+ const { trigger } = useAPI((api) => api.posts.post);
350
+ trigger({ body: { title: "New" } });
351
+ // → Automatically revalidates 'posts' tag
352
+ // → Component A refetches automatically!
353
+ ```
354
+
355
+ ### Stale Time
356
+
357
+ Control how long cached data is considered fresh:
358
+
359
+ ```typescript
360
+ const useAPI = createEnlaceHookReact<ApiSchema>(
361
+ "https://api.example.com",
362
+ {},
363
+ {
364
+ staleTime: 5000, // 5 seconds
365
+ }
366
+ );
367
+ ```
368
+
369
+ - `staleTime: 0` (default) — Always revalidate on mount
370
+ - `staleTime: 5000` — Data is fresh for 5 seconds
371
+ - `staleTime: Infinity` — Never revalidate automatically
372
+
373
+ ### Manual Tag Override (Optional)
374
+
375
+ Override auto-generated tags when needed:
376
+
377
+ ```typescript
378
+ // Custom cache tags
379
+ const { data } = useAPI((api) => api.posts.get({ tags: ["my-custom-tag"] }));
380
+
381
+ // Custom revalidation tags
382
+ trigger({
383
+ body: { title: "New" },
384
+ revalidateTags: ["posts", "dashboard"], // Override auto-generated
385
+ });
386
+ ```
387
+
388
+ ### Disable Auto-Revalidation
389
+
390
+ ```typescript
391
+ const useAPI = createEnlaceHookReact<ApiSchema>(
392
+ "https://api.example.com",
393
+ {},
394
+ {
395
+ autoGenerateTags: false, // Disable auto tag generation
396
+ autoRevalidateTags: false, // Disable auto revalidation
397
+ }
398
+ );
399
+ ```
400
+
401
+ ## Hook Options
402
+
403
+ ```typescript
404
+ const useAPI = createEnlaceHookReact<ApiSchema>(
405
+ "https://api.example.com",
406
+ {
407
+ // Default fetch options
408
+ headers: { Authorization: "Bearer token" },
409
+ },
410
+ {
411
+ // Hook options
412
+ autoGenerateTags: true, // Auto-generate cache tags from URL
413
+ autoRevalidateTags: true, // Auto-revalidate after mutations
414
+ staleTime: 0, // Cache freshness duration (ms)
415
+ }
416
+ );
417
+ ```
418
+
419
+ ### Async Headers
420
+
421
+ 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):
422
+
423
+ ```typescript
424
+ // Static headers
425
+ const useAPI = createEnlaceHookReact<ApiSchema>("https://api.example.com", {
426
+ headers: { Authorization: "Bearer token" },
427
+ });
428
+
429
+ // Sync function
430
+ const useAPI = createEnlaceHookReact<ApiSchema>("https://api.example.com", {
431
+ headers: () => ({ Authorization: `Bearer ${getToken()}` }),
432
+ });
433
+
434
+ // Async function
435
+ const useAPI = createEnlaceHookReact<ApiSchema>("https://api.example.com", {
436
+ headers: async () => {
437
+ const token = await getTokenFromStorage();
438
+ return { Authorization: `Bearer ${token}` };
439
+ },
440
+ });
441
+ ```
442
+
443
+ This also works for per-request headers:
444
+
445
+ ```typescript
446
+ const { data } = useAPI((api) =>
447
+ api.posts.get({
448
+ headers: async () => {
449
+ const token = await refreshToken();
450
+ return { Authorization: `Bearer ${token}` };
451
+ },
452
+ })
453
+ );
454
+ ```
455
+
456
+ ### Global Callbacks
457
+
458
+ You can set up global `onSuccess` and `onError` callbacks that are called for every request:
459
+
460
+ ```typescript
461
+ const useAPI = createEnlaceHookReact<ApiSchema>(
462
+ "https://api.example.com",
463
+ {
464
+ headers: { Authorization: "Bearer token" },
465
+ },
466
+ {
467
+ onSuccess: (payload) => {
468
+ console.log("Request succeeded:", payload.status, payload.data);
469
+ },
470
+ onError: (payload) => {
471
+ if (payload.status === 0) {
472
+ // Network error
473
+ console.error("Network error:", payload.error.message);
474
+ } else {
475
+ // HTTP error (4xx, 5xx)
476
+ console.error("HTTP error:", payload.status, payload.error);
477
+ }
478
+ },
479
+ }
480
+ );
481
+ ```
482
+
483
+ **Callback Payloads:**
484
+
485
+ ```typescript
486
+ // onSuccess payload
487
+ type EnlaceCallbackPayload<T> = {
488
+ status: number;
489
+ data: T;
490
+ headers: Headers;
491
+ };
492
+
493
+ // onError payload (HTTP error or network error)
494
+ type EnlaceErrorCallbackPayload<T> =
495
+ | { status: number; error: T; headers: Headers } // HTTP error
496
+ | { status: 0; error: Error; headers: null }; // Network error
497
+ ```
498
+
499
+ **Use cases:**
500
+
501
+ - Global error logging/reporting
502
+ - Toast notifications for all API errors
503
+ - Authentication refresh on 401 errors
504
+ - Analytics tracking
505
+
506
+ ## Return Types
507
+
508
+ ### Query Mode
509
+
510
+ ```typescript
511
+ // Basic usage
512
+ const result = useAPI((api) => api.posts.get());
513
+
514
+ // With options
515
+ const result = useAPI(
516
+ (api) => api.posts.get(),
517
+ { enabled: true } // Skip fetching when false
518
+ );
519
+
520
+ type UseEnlaceQueryResult<TData, TError> = {
521
+ loading: boolean; // No cached data and fetching
522
+ fetching: boolean; // Request in progress
523
+ data: TData | undefined;
524
+ error: TError | undefined;
525
+ };
526
+ ```
527
+
528
+ ### Selector Mode
529
+
530
+ ```typescript
531
+ type UseEnlaceSelectorResult<TMethod> = {
532
+ trigger: TMethod; // Function to trigger the request
533
+ loading: boolean;
534
+ fetching: boolean;
535
+ data: TData | undefined;
536
+ error: TError | undefined;
537
+ };
538
+ ```
539
+
540
+ ### Request Options
541
+
542
+ ```typescript
543
+ type RequestOptions = {
544
+ query?: Record<string, unknown>; // Query parameters
545
+ body?: TBody; // Request body
546
+ headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>); // Request headers
547
+ tags?: string[]; // Cache tags (GET only)
548
+ revalidateTags?: string[]; // Tags to invalidate after mutation
549
+ pathParams?: Record<string, string | number>; // Dynamic path parameters
550
+ };
551
+ ```
552
+
553
+ ---
554
+
555
+ ## Next.js Integration
556
+
557
+ ### Server Components
558
+
559
+ Use `createEnlaceNext` from `enlace` for server components:
560
+
561
+ ```typescript
562
+ import { createEnlaceNext } from "enlace";
563
+
564
+ type ApiError = { message: string };
565
+
566
+ const api = createEnlaceNext<ApiSchema, ApiError>("https://api.example.com", {}, {
567
+ autoGenerateTags: true,
568
+ });
569
+
570
+ export default async function Page() {
571
+ const { data } = await api.posts.get({
572
+ revalidate: 60, // ISR: revalidate every 60 seconds
573
+ });
574
+
575
+ return <PostList posts={data} />;
576
+ }
577
+ ```
578
+
579
+ ### Client Components
580
+
581
+ Use `createEnlaceHookNext` from `enlace/hook` for client components:
582
+
583
+ ```typescript
584
+ "use client";
585
+
586
+ import { createEnlaceHookNext } from "enlace/hook";
587
+
588
+ type ApiError = { message: string };
589
+
590
+ const useAPI = createEnlaceHookNext<ApiSchema, ApiError>(
591
+ "https://api.example.com"
592
+ );
593
+ ```
594
+
595
+ ### Server-Side Revalidation
596
+
597
+ Trigger Next.js cache revalidation after mutations:
598
+
599
+ ```typescript
600
+ // actions.ts
601
+ "use server";
602
+
603
+ import { revalidateTag, revalidatePath } from "next/cache";
604
+
605
+ export async function revalidateAction(tags: string[], paths: string[]) {
606
+ for (const tag of tags) {
607
+ revalidateTag(tag);
608
+ }
609
+ for (const path of paths) {
610
+ revalidatePath(path);
611
+ }
612
+ }
613
+ ```
614
+
615
+ ```typescript
616
+ // useAPI.ts
617
+ import { createEnlaceHookNext } from "enlace/hook";
618
+ import { revalidateAction } from "./actions";
619
+
620
+ type ApiError = { message: string };
621
+
622
+ const useAPI = createEnlaceHookNext<ApiSchema, ApiError>(
623
+ "/api",
624
+ {},
625
+ {
626
+ revalidator: revalidateAction,
627
+ }
628
+ );
629
+ ```
630
+
631
+ **In components:**
632
+
633
+ ```typescript
634
+ function CreatePost() {
635
+ const { trigger } = useAPI((api) => api.posts.post);
636
+
637
+ const handleCreate = () => {
638
+ trigger({
639
+ body: { title: "New Post" },
640
+ revalidateTags: ["posts"], // Passed to revalidator
641
+ revalidatePaths: ["/posts"], // Passed to revalidator
642
+ });
643
+ };
644
+ }
645
+ ```
646
+
647
+ ### Next.js Request Options
648
+
649
+ ```typescript
650
+ api.posts.get({
651
+ tags: ["posts"], // Next.js cache tags
652
+ revalidate: 60, // ISR revalidation (seconds)
653
+ revalidateTags: ["posts"], // Tags to invalidate after mutation
654
+ revalidatePaths: ["/"], // Paths to revalidate after mutation
655
+ skipRevalidator: false, // Skip server-side revalidation
656
+ });
657
+ ```
658
+
659
+ ### Relative URLs
660
+
661
+ Works with Next.js API routes:
662
+
663
+ ```typescript
664
+ // Client component calling /api/posts
665
+ const useAPI = createEnlaceHookNext<ApiSchema, ApiError>("/api");
666
+ ```
667
+
668
+ ---
669
+
670
+ ## API Reference
671
+
672
+ ### `createEnlaceHookReact<TSchema, TDefaultError>(baseUrl, options?, hookOptions?)`
673
+
674
+ Creates a React hook for making API calls.
675
+
676
+ ### `createEnlaceHookNext<TSchema, TDefaultError>(baseUrl, options?, hookOptions?)`
677
+
678
+ Creates a Next.js hook with server revalidation support.
679
+
680
+ ### `createEnlace<TSchema, TDefaultError>(baseUrl, options?, callbacks?)`
681
+
682
+ Creates a typed API client (non-hook, for direct calls or server components).
683
+
684
+ ### `createEnlaceNext<TSchema, TDefaultError>(baseUrl, options?, nextOptions?)`
685
+
686
+ Creates a Next.js typed API client with caching support.
687
+
688
+ **Generic Parameters:**
689
+
690
+ - `TSchema` — API schema type defining endpoints
691
+ - `TDefaultError` — Default error type for all endpoints (default: `unknown`)
692
+
693
+ **Function Parameters:**
694
+
695
+ - `baseUrl` — Base URL for requests
696
+ - `options` — Default fetch options (headers, cache, etc.)
697
+ - `hookOptions` / `callbacks` / `nextOptions` — Additional configuration
698
+
699
+ **Hook Options:**
700
+
701
+ ```typescript
702
+ type EnlaceHookOptions = {
703
+ autoGenerateTags?: boolean; // default: true
704
+ autoRevalidateTags?: boolean; // default: true
705
+ staleTime?: number; // default: 0
706
+ onSuccess?: (payload: EnlaceCallbackPayload<unknown>) => void;
707
+ onError?: (payload: EnlaceErrorCallbackPayload<unknown>) => void;
708
+ };
709
+ ```
710
+
711
+ ### Re-exports from enlace-core
712
+
713
+ - `Endpoint` — Type helper for schema definition
714
+ - `EnlaceResponse` — Response type
715
+ - `EnlaceOptions` — Fetch options type
716
+
717
+ ## OpenAPI Generation
718
+
719
+ Generate OpenAPI 3.0 specs from your TypeScript schema using [`enlace-openapi`](../openapi/README.md):
720
+
721
+ ```bash
722
+ npm install enlace-openapi
723
+ enlace-openapi --schema ./types/APISchema.ts --output ./openapi.json
724
+ ```
725
+
726
+ ## License
727
+
728
+ MIT