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 +648 -85
- package/dist/hook/index.d.mts +220 -0
- package/dist/hook/index.d.ts +220 -0
- package/dist/{next/hook → hook}/index.js +334 -169
- package/dist/hook/index.mjs +590 -0
- package/dist/index.d.mts +74 -69
- package/dist/index.d.ts +74 -69
- package/dist/index.js +65 -314
- package/dist/index.mjs +67 -314
- package/package.json +17 -12
- package/dist/next/hook/index.d.mts +0 -124
- package/dist/next/hook/index.d.ts +0 -124
- package/dist/next/hook/index.mjs +0 -444
- package/dist/next/index.d.mts +0 -74
- package/dist/next/index.d.ts +0 -74
- package/dist/next/index.js +0 -111
- package/dist/next/index.mjs +0 -95
package/README.md
CHANGED
|
@@ -11,20 +11,26 @@ npm install enlace
|
|
|
11
11
|
## Quick Start
|
|
12
12
|
|
|
13
13
|
```typescript
|
|
14
|
-
import {
|
|
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:
|
|
19
|
-
$post: Endpoint<Post,
|
|
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:
|
|
22
|
-
$delete:
|
|
26
|
+
$get: Post; // Simple: just data type
|
|
27
|
+
$delete: void; // Simple: void response
|
|
23
28
|
};
|
|
24
29
|
};
|
|
25
30
|
};
|
|
26
31
|
|
|
27
|
-
|
|
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 =
|
|
37
|
-
const { data } = useAPI((api) => api.any.path.you.want
|
|
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 =
|
|
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:
|
|
56
|
-
$post: Endpoint<User>;
|
|
57
|
-
_: {
|
|
58
|
-
|
|
59
|
-
$
|
|
60
|
-
$
|
|
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:
|
|
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
|
|
70
|
-
api.users[123]
|
|
71
|
-
api.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
|
-
|
|
108
|
+
#### `EndpointWithQuery<TData, TQuery, TError?>`
|
|
109
|
+
|
|
110
|
+
For endpoints with typed query parameters:
|
|
75
111
|
|
|
76
112
|
```typescript
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
98
|
-
api.posts
|
|
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 (
|
|
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]
|
|
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]
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
494
|
+
const { data } = useAPI((api) => api.posts.$get());
|
|
193
495
|
|
|
194
496
|
// Component B: creates a post
|
|
195
|
-
const { trigger } = useAPI((api) => api.posts
|
|
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 =
|
|
207
|
-
|
|
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
|
|
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"],
|
|
532
|
+
revalidateTags: ["posts", "dashboard"],
|
|
227
533
|
});
|
|
228
534
|
```
|
|
229
535
|
|
|
230
|
-
###
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 =
|
|
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,
|
|
251
|
-
autoRevalidateTags: true,
|
|
252
|
-
staleTime: 0,
|
|
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;
|
|
264
|
-
fetching: boolean;
|
|
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;
|
|
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 `
|
|
755
|
+
Use `enlaceNext` from `enlace` for server components:
|
|
291
756
|
|
|
292
757
|
```typescript
|
|
293
|
-
import {
|
|
758
|
+
import { enlaceNext } from "enlace";
|
|
294
759
|
|
|
295
|
-
|
|
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
|
|
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 `
|
|
777
|
+
Use `enlaceHookNext` from `enlace/hook` for client components:
|
|
311
778
|
|
|
312
779
|
```typescript
|
|
313
780
|
"use client";
|
|
314
781
|
|
|
315
|
-
import {
|
|
782
|
+
import { enlaceHookNext } from "enlace/hook";
|
|
783
|
+
|
|
784
|
+
type ApiError = { message: string };
|
|
316
785
|
|
|
317
|
-
const useAPI =
|
|
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 {
|
|
811
|
+
import { enlaceHookNext } from "enlace/hook";
|
|
343
812
|
import { revalidateAction } from "./actions";
|
|
344
813
|
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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"],
|
|
360
|
-
revalidatePaths: ["/posts"],
|
|
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
|
|
370
|
-
tags: ["posts"],
|
|
371
|
-
revalidate: 60,
|
|
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: ["/"],
|
|
374
|
-
|
|
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 =
|
|
892
|
+
const useAPI = enlaceHookNext<ApiSchema, ApiError>("/api");
|
|
385
893
|
```
|
|
386
894
|
|
|
387
895
|
---
|
|
388
896
|
|
|
389
897
|
## API Reference
|
|
390
898
|
|
|
391
|
-
### `
|
|
899
|
+
### `enlaceHookReact<TSchema, TDefaultError>(baseUrl, options?, hookOptions?)`
|
|
392
900
|
|
|
393
901
|
Creates a React hook for making API calls.
|
|
394
902
|
|
|
395
|
-
|
|
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` —
|
|
924
|
+
- `hookOptions` / `callbacks` / `nextOptions` — Additional configuration
|
|
399
925
|
|
|
400
926
|
**Hook Options:**
|
|
927
|
+
|
|
401
928
|
```typescript
|
|
402
929
|
type EnlaceHookOptions = {
|
|
403
|
-
autoGenerateTags?: boolean;
|
|
404
|
-
autoRevalidateTags?: boolean;
|
|
405
|
-
staleTime?: number;
|
|
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
|
|
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
|