@tahanabavi/typefetch 1.0.0

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,183 @@
1
+ # TypeFetch
2
+
3
+ TypeFetch is a type-safe client for working with APIs, built with TypeScript and Zod. This project allows you to define API contracts and safely use types, while also supporting middlewares, error handling, and response transformation.
4
+
5
+ ---
6
+
7
+ ## Features
8
+
9
+ * Fully type-safe using TypeScript and Zod
10
+ * Define contracts for modules and endpoints
11
+ * Support for middlewares to add custom behavior before or after requests
12
+ * Error handling with the `RichError` class
13
+ * Ability to transform responses using a response transformer
14
+ * Authentication support via token
15
+
16
+ ---
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install typefetch
22
+ # or
23
+ yarn add typefetch
24
+ ```
25
+
26
+ ---
27
+
28
+ ## Defining Contracts
29
+
30
+ Contracts are defined using the `Contracts` and `EndpointDef` types
31
+
32
+ ```ts
33
+ import { z } from "zod";
34
+
35
+ const contracts = {
36
+ user: {
37
+ getUser: {
38
+ method: "GET",
39
+ path: "/user/:id",
40
+ auth: true,
41
+ request: z.object({ id: z.string() }),
42
+ response: z.object({ id: z.string(), name: z.string() }),
43
+ },
44
+ createUser: {
45
+ method: "POST",
46
+ path: "/user",
47
+ request: z.object({ name: z.string() }),
48
+ response: z.object({ id: z.string(), name: z.string() }),
49
+ },
50
+ },
51
+ } as const;
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Using `ApiClient`
57
+
58
+ ```ts
59
+ import { ApiClient, RichError } from "typefetch";
60
+
61
+ const client = new ApiClient(
62
+ {
63
+ baseUrl: "https://api.example.com",
64
+ token: "your-auth-token",
65
+ },
66
+ contracts
67
+ );
68
+
69
+ client.init();
70
+
71
+ const user = await client.user.getUser({ id: "123" });
72
+ const newUser = await client.user.createUser({ name: "Taha" });
73
+ ```
74
+
75
+ ---
76
+
77
+ ## Error Handling
78
+
79
+ All errors are provided via the `RichError` class. You can define a custom error handler:
80
+
81
+ ```ts
82
+ client.onError((error: RichError) => {
83
+ console.error("API Error:", error.message, error.status);
84
+ });
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Middlewares
90
+
91
+ You can add custom behavior before or after requests. Middlewares work similarly to Express:
92
+
93
+ ```ts
94
+ client.use(async (ctx, next, options) => {
95
+ console.log("Request URL:", ctx.url);
96
+ const response = await next();
97
+ console.log("Response status:", response.status);
98
+ return response;
99
+ });
100
+ ```
101
+
102
+ ### Built-in Middlewares
103
+
104
+ This project provides some built-in middlewares:
105
+
106
+ ```ts
107
+ import {
108
+ LoggingMiddleware,
109
+ RetryMiddleware,
110
+ AuthMiddleware,
111
+ CacheMiddleware,
112
+ } from "typefetch/middlewares";
113
+
114
+ client.use(LoggingMiddleware);
115
+ client.use(RetryMiddleware, { maxRetries: 3, delay: 100 });
116
+ client.use(AuthMiddleware, { refreshToken: () => "your-auth-token" });
117
+ client.use(CacheMiddleware, { ttl: 60 * 1000 });
118
+ ```
119
+
120
+ * `LoggingMiddleware` – Logs requests and responses
121
+ * `RetryMiddleware` – Retries failed requests
122
+ * `AuthMiddleware` – Automatically adds Authorization headers
123
+ * `CacheMiddleware` – Caches responses to reduce repeated requests
124
+
125
+ ---
126
+
127
+ ## Response Transformation
128
+
129
+ You can transform the response format before returning it:
130
+
131
+ ```ts
132
+ client.useResponseTransform((data) => {
133
+ return { ...data, fetchedAt: new Date() };
134
+ });
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Important Notes
140
+
141
+ * Always call `client.init()` before using endpoints.
142
+ * Types are automatically inferred from Zod, making inputs and outputs type-safe.
143
+ * Middleware execution order: first added middleware runs last, last added middleware runs first.
144
+
145
+ ---
146
+
147
+ ## Full Example
148
+
149
+ ```ts
150
+ import { z } from "zod";
151
+ import { ApiClient, RichError } from "typefetch";
152
+ import { LoggingMiddleware, RetryMiddleware } from "typefetch/middlewares";
153
+
154
+ const contracts = {
155
+ post: {
156
+ getPost: {
157
+ method: "GET",
158
+ path: "/posts/:id",
159
+ auth: true,
160
+ request: z.object({ id: z.string() }),
161
+ response: z.object({ id: z.string(), title: z.string() }),
162
+ },
163
+ },
164
+ } as const;
165
+
166
+ const client = new ApiClient(
167
+ { baseUrl: "https://api.example.com", token: "abc123" },
168
+ contracts
169
+ );
170
+
171
+ client.init();
172
+ client.use(LoggingMiddleware);
173
+ client.use(RetryMiddleware, { maxRetries: 2 });
174
+
175
+ client.onError((err: RichError) => {
176
+ console.error("Error:", err.message);
177
+ });
178
+
179
+ (async () => {
180
+ const post = await client.post.getPost({ id: "1" });
181
+ console.log(post);
182
+ })();
183
+ ```
package/jest.config.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { Config } from "jest";
2
+
3
+ const config: Config = {
4
+ preset: "ts-jest",
5
+ testEnvironment: "node",
6
+ roots: ["<rootDir>/src"],
7
+ moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
8
+ transform: {
9
+ "^.+\\.(ts|tsx)$": "ts-jest",
10
+ },
11
+ testMatch: ["**/*.test.ts", "**/*.test.tsx"],
12
+ collectCoverage: true,
13
+ collectCoverageFrom: ["src/**/*.{ts,tsx}", "!src/**/*.d.ts"],
14
+ coverageDirectory: "coverage",
15
+ clearMocks: true,
16
+ };
17
+
18
+ export default config;
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@tahanabavi/typefetch",
3
+ "version": "1.0.0",
4
+ "description": "A fully type-safe, extensible API client for TypeScript projects, featuring global error handling, configurable middleware, automatic retries, auth refresh, response transforms, and seamless contract integration. Designed for large-scale applications and developer-friendly API interactions.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "test": "jest",
10
+ "test:watch": "jest --watch",
11
+ "test:coverage": "jest --coverage"
12
+ },
13
+ "devDependencies": {
14
+ "@types/jest": "^30.0.0",
15
+ "jest": "^30.1.3",
16
+ "jest-fetch-mock": "^3.0.3",
17
+ "ts-jest": "^29.4.1",
18
+ "typescript": "^5.9.2",
19
+ "zod": "^3.25.76"
20
+ },
21
+ "author": "",
22
+ "license": "ISC",
23
+ "dependencies": {
24
+ "ts-node": "^10.9.2"
25
+ }
26
+ }
@@ -0,0 +1,137 @@
1
+ import { z, ZodError } from "zod";
2
+ import { ApiClient, RichError } from "../client";
3
+ import { Contracts } from "../types";
4
+
5
+ // Mock fetch globally
6
+ global.fetch = jest.fn();
7
+
8
+ const contracts: Contracts = {
9
+ user: {
10
+ getUser: {
11
+ method: "GET",
12
+ path: "/user",
13
+ request: z.object({ id: z.string() }),
14
+ response: z.object({ id: z.string(), name: z.string() }),
15
+ },
16
+ createUser: {
17
+ method: "POST",
18
+ path: "/user",
19
+ auth: true,
20
+ request: z.object({ name: z.string() }),
21
+ response: z.object({ id: z.string(), name: z.string() }),
22
+ },
23
+ },
24
+ };
25
+
26
+ describe("ApiClient", () => {
27
+ let client: ApiClient<typeof contracts>;
28
+
29
+ beforeEach(() => {
30
+ jest.clearAllMocks();
31
+ client = new ApiClient({ baseUrl: "https://api.test.com" }, contracts);
32
+ client.init();
33
+ });
34
+
35
+ it("should initialize modules correctly", () => {
36
+ expect(client.modules.user).toBeDefined();
37
+ expect(typeof client.modules.user.getUser).toBe("function");
38
+ });
39
+
40
+ it("should call fetch with correct URL and headers", async () => {
41
+ (fetch as jest.Mock).mockResolvedValueOnce({
42
+ ok: true,
43
+ json: async () => ({ id: "1", name: "John" }),
44
+ });
45
+
46
+ const res = await client.modules.user.getUser({ id: "1" });
47
+ expect(fetch).toHaveBeenCalledWith("https://api.test.com/user", {
48
+ method: "GET",
49
+ headers: { "Content-Type": "application/json" },
50
+ body: undefined,
51
+ });
52
+ expect(res).toEqual({ id: "1", name: "John" });
53
+ });
54
+
55
+ it("should throw validation error if input is invalid", async () => {
56
+ await expect(client.modules.user.getUser({} as any))
57
+ .rejects.toBeInstanceOf(ZodError);
58
+ });
59
+
60
+ it("should handle auth header when token is provided", async () => {
61
+ const authedClient = new ApiClient(
62
+ { baseUrl: "https://api.test.com", token: "mytoken" },
63
+ contracts
64
+ );
65
+ authedClient.init();
66
+
67
+ (fetch as jest.Mock).mockResolvedValueOnce({
68
+ ok: true,
69
+ json: async () => ({ id: "2", name: "Alice" }),
70
+ });
71
+
72
+ await authedClient.modules.user.createUser({ name: "Alice" });
73
+
74
+ expect(fetch).toHaveBeenCalledWith("https://api.test.com/user", {
75
+ method: "POST",
76
+ headers: {
77
+ "Content-Type": "application/json",
78
+ Authorization: "Bearer mytoken",
79
+ },
80
+ body: JSON.stringify({ name: "Alice" }),
81
+ });
82
+ });
83
+
84
+ it("should throw error if auth required and no token provided", async () => {
85
+ await expect(
86
+ client.modules.user.createUser({ name: "Alice" })
87
+ ).rejects.toThrow(RichError);
88
+ });
89
+
90
+ it("should call errorHandler when error occurs", async () => {
91
+ const handler = jest.fn();
92
+ client.onError(handler);
93
+
94
+ (fetch as jest.Mock).mockResolvedValueOnce({
95
+ ok: false,
96
+ status: 400,
97
+ statusText: "Bad Request",
98
+ json: async () => ({ message: "Invalid input" }),
99
+ });
100
+
101
+ await expect(client.modules.user.getUser({ id: "bad" })).rejects.toThrow();
102
+
103
+ expect(handler).toHaveBeenCalled();
104
+ });
105
+
106
+ it("should apply responseTransform", async () => {
107
+ client.useResponseTransform((data) => ({ ...data, transformed: true }));
108
+
109
+ (fetch as jest.Mock).mockResolvedValueOnce({
110
+ ok: true,
111
+ json: async () => ({ id: "1", name: "John" }),
112
+ });
113
+
114
+ const res = await client.modules.user.getUser({ id: "1" });
115
+ expect(res).toEqual({ id: "1", name: "John", transformed: true });
116
+ });
117
+
118
+ it("should execute middleware in order", async () => {
119
+ const logs: string[] = [];
120
+
121
+ client.use(async (ctx, next) => {
122
+ logs.push("before");
123
+ const res = await next();
124
+ logs.push("after");
125
+ return res;
126
+ });
127
+
128
+ (fetch as jest.Mock).mockResolvedValueOnce({
129
+ ok: true,
130
+ json: async () => ({ id: "1", name: "John" }),
131
+ });
132
+
133
+ await client.modules.user.getUser({ id: "1" });
134
+
135
+ expect(logs).toEqual(["before", "after"]);
136
+ });
137
+ });
@@ -0,0 +1,108 @@
1
+ import { authMiddleware } from "../middlewares/auth";
2
+ import { cacheMiddleware } from "../middlewares/cache";
3
+ import { loggingMiddleware } from "../middlewares/logging";
4
+ import { retryMiddleware } from "../middlewares/retry";
5
+
6
+ describe("middlewares", () => {
7
+ const mockCtx = (url = "/test", method = "GET") => ({
8
+ url,
9
+ init: { method, headers: {} as Record<string, string> },
10
+ });
11
+
12
+ const mockNext = (response: any = { ok: true, status: 200 }) =>
13
+ jest.fn().mockResolvedValue(new Response(JSON.stringify(response)));
14
+
15
+ // ---------------- AUTH ----------------
16
+ it("authMiddleware should add refreshed token", async () => {
17
+ const ctx = mockCtx();
18
+ const next = mockNext();
19
+
20
+ await authMiddleware(ctx, next, {
21
+ refreshToken: async () => "NEW_TOKEN",
22
+ });
23
+
24
+ expect(ctx.init.headers["Authorization"]).toBe("Bearer NEW_TOKEN");
25
+ expect(next).toHaveBeenCalled();
26
+ });
27
+
28
+ it("authMiddleware should skip if no refreshToken provided", async () => {
29
+ const ctx = mockCtx();
30
+ const next = mockNext();
31
+
32
+ await authMiddleware(ctx, next, {});
33
+ expect(ctx.init.headers["Authorization"]).toBeUndefined();
34
+ expect(next).toHaveBeenCalled();
35
+ });
36
+
37
+ // ---------------- CACHE ----------------
38
+ it("cacheMiddleware should cache GET responses", async () => {
39
+ const ctx = mockCtx("/users", "GET");
40
+ const next = mockNext({ users: [1, 2, 3] });
41
+ const middleware = cacheMiddleware({ ttl: 1000 });
42
+
43
+ const res1 = await middleware(ctx, next);
44
+ const res2 = await middleware(ctx, next);
45
+
46
+ expect(next).toHaveBeenCalledTimes(1); // only first time
47
+ const data2 = await res2.json();
48
+ expect(data2.users).toEqual([1, 2, 3]);
49
+ });
50
+
51
+ it("cacheMiddleware should bypass cache for non-GET requests", async () => {
52
+ const ctx = mockCtx("/users", "POST");
53
+ const next = mockNext({ ok: true });
54
+ const middleware = cacheMiddleware();
55
+
56
+ await middleware(ctx, next);
57
+ expect(next).toHaveBeenCalledTimes(1);
58
+ });
59
+
60
+ // ---------------- LOGGING ----------------
61
+ it("loggingMiddleware should log request and response", async () => {
62
+ const ctx = mockCtx();
63
+ const next = mockNext();
64
+
65
+ const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
66
+
67
+ await loggingMiddleware(ctx, next, {
68
+ logRequest: true,
69
+ logResponse: true,
70
+ debug: true,
71
+ });
72
+
73
+ expect(logSpy).toHaveBeenCalledWith("➡️ Request:", ctx.url, ctx.init);
74
+ expect(logSpy).toHaveBeenCalledWith("⬅️ Response:", 200);
75
+
76
+ logSpy.mockRestore();
77
+ });
78
+
79
+ // ---------------- RETRY ----------------
80
+ it("retryMiddleware should retry failed requests", async () => {
81
+ const ctx = mockCtx();
82
+ let attempt = 0;
83
+
84
+ const next = jest.fn().mockImplementation(() => {
85
+ attempt++;
86
+ if (attempt < 2) throw new Error("fail");
87
+ return Promise.resolve(new Response(JSON.stringify({ ok: true })));
88
+ });
89
+
90
+ const middleware = retryMiddleware({ maxRetries: 3, delay: 10 });
91
+
92
+ const res = await middleware(ctx, next);
93
+ const json = await res.json();
94
+
95
+ expect(json.ok).toBe(true);
96
+ expect(next).toHaveBeenCalledTimes(2);
97
+ });
98
+
99
+ it("retryMiddleware should throw after exceeding maxRetries", async () => {
100
+ const ctx = mockCtx();
101
+ const next = jest.fn().mockRejectedValue(new Error("fail always"));
102
+
103
+ const middleware = retryMiddleware({ maxRetries: 2, delay: 10 });
104
+
105
+ await expect(middleware(ctx, next)).rejects.toThrow("fail always");
106
+ expect(next).toHaveBeenCalledTimes(3); // initial + 2 retries
107
+ });
108
+ });
package/src/client.ts ADDED
@@ -0,0 +1,142 @@
1
+ import {
2
+ Contracts,
3
+ EndpointDef,
4
+ EndpointDefZ,
5
+ Middleware,
6
+ ErrorLike,
7
+ EndpointMethods,
8
+ } from "./types";
9
+ import { z } from "zod";
10
+
11
+ export class RichError extends Error implements ErrorLike {
12
+ status?: number;
13
+ code?: string;
14
+ title?: string;
15
+ detail?: string;
16
+ errors?: Record<string, string[]>;
17
+
18
+ constructor(error: Partial<ErrorLike> & { message: string }) {
19
+ super(error.message);
20
+ Object.assign(this, error);
21
+ }
22
+ }
23
+
24
+ export class ApiClient<C extends Contracts, E extends ErrorLike = RichError> {
25
+ private middlewares: Array<{ fn: Middleware; options?: any }> = [];
26
+ private errorHandler?: (error: E) => void;
27
+ private responseTransform: (data: any) => any = (d) => d;
28
+
29
+ private _modules!: {
30
+ [M in keyof C]: EndpointMethods<C[M]>;
31
+ };
32
+
33
+ constructor(
34
+ private config: { baseUrl: string; token?: string },
35
+ private contracts: C
36
+ ) {}
37
+
38
+ init() {
39
+ const modules = {} as {
40
+ [M in keyof C]: EndpointMethods<C[M]>;
41
+ };
42
+
43
+ for (const moduleName in this.contracts) {
44
+ const module = this.contracts[moduleName];
45
+ (modules as any)[moduleName] = {} as EndpointMethods<typeof module>;
46
+
47
+ for (const endpointName in module) {
48
+ const endpoint = module[endpointName] as EndpointDefZ;
49
+
50
+ (modules as any)[moduleName][endpointName] = (
51
+ input: z.infer<(typeof endpoint)["request"]>
52
+ ) => this.request(endpoint, input);
53
+ }
54
+ }
55
+
56
+ this._modules = modules;
57
+ }
58
+
59
+ get modules() {
60
+ return this._modules;
61
+ }
62
+
63
+ use<T>(middleware: Middleware<T>, options?: T) {
64
+ this.middlewares.push({ fn: middleware, options });
65
+ }
66
+
67
+ onError(handler: (error: E) => void) {
68
+ this.errorHandler = handler;
69
+ }
70
+
71
+ useResponseTransform(fn: (data: any) => any) {
72
+ this.responseTransform = fn;
73
+ }
74
+
75
+ private async request<TReq extends z.ZodTypeAny, TRes extends z.ZodTypeAny>(
76
+ endpoint: EndpointDef<TReq, TRes>,
77
+ input: z.infer<TReq>
78
+ ): Promise<z.infer<TRes>> {
79
+ endpoint.request.parse(input);
80
+
81
+ if (endpoint.auth && !this.config.token) {
82
+ const error = this.createError({
83
+ message: `Missing token for ${endpoint.path}`,
84
+ status: 401,
85
+ code: "NO_TOKEN",
86
+ });
87
+ this.errorHandler?.(error as unknown as E);
88
+ throw error;
89
+ }
90
+
91
+ const headers: HeadersInit = { "Content-Type": "application/json" };
92
+ if (endpoint.auth && this.config.token)
93
+ headers["Authorization"] = `Bearer ${this.config.token}`;
94
+
95
+ const ctx = {
96
+ url: this.config.baseUrl + endpoint.path,
97
+ init: {
98
+ method: endpoint.method,
99
+ headers,
100
+ body: endpoint.method !== "GET" ? JSON.stringify(input) : undefined,
101
+ },
102
+ };
103
+
104
+ const runner = this.middlewares.reduceRight(
105
+ (next, mw) => () => mw.fn(ctx, next, mw.options),
106
+ () => fetch(ctx.url, ctx.init)
107
+ );
108
+
109
+ try {
110
+ const res = await runner();
111
+ if (!res.ok) {
112
+ const errorData = await res.json().catch(() => ({}));
113
+ const error = this.createError({
114
+ message: errorData.message || res.statusText,
115
+ status: res.status,
116
+ code: errorData.code,
117
+ title: errorData.title,
118
+ detail: errorData.detail,
119
+ errors: errorData.errors,
120
+ });
121
+ this.errorHandler?.(error as unknown as E);
122
+ throw error;
123
+ }
124
+
125
+ const json = await res.json();
126
+ return this.responseTransform(endpoint.response.parse(json));
127
+ } catch (err: any) {
128
+ const error = this.normalizeError(err);
129
+ this.errorHandler?.(error as unknown as E);
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ private createError(error: Partial<RichError> & { message: string }) {
135
+ return new RichError(error);
136
+ }
137
+
138
+ private normalizeError(err: any) {
139
+ if (err instanceof RichError) return err;
140
+ return this.createError({ message: err.message || "Unknown error" });
141
+ }
142
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./types";
2
+ export * from "./client";
3
+ export * from "./middlewares/logging";
4
+ export * from "./middlewares/retry";
5
+ export * from "./middlewares/auth";
6
+ export * from "./middlewares/cache";
@@ -0,0 +1,19 @@
1
+ import { Middleware } from "../types";
2
+
3
+ export type AuthOptions = {
4
+ refreshToken?: () => Promise<string>;
5
+ };
6
+
7
+ export const authMiddleware: Middleware<AuthOptions> = async (ctx, next, options) => {
8
+ if (options?.refreshToken) {
9
+ try {
10
+ const newToken = await options.refreshToken();
11
+ ctx.init.headers = {
12
+ ...ctx.init.headers,
13
+ Authorization: `Bearer ${newToken}`
14
+ };
15
+ } catch {}
16
+ }
17
+
18
+ return next();
19
+ };
@@ -0,0 +1,26 @@
1
+ import { MiddlewareContext, MiddlewareNext } from "../types";
2
+
3
+ export type CacheOptions = { ttl?: number };
4
+
5
+ export const cacheMiddleware = (options: CacheOptions = {}) => {
6
+ const { ttl = 60000 } = options;
7
+ const cache = new Map<string, { data: any; expires: number }>();
8
+
9
+ return async (ctx: MiddlewareContext, next: MiddlewareNext) => {
10
+ if (ctx.init.method === "GET") {
11
+ const cached = cache.get(ctx.url);
12
+ const now = Date.now();
13
+ if (cached && cached.expires > now)
14
+ return new Response(JSON.stringify(cached.data));
15
+
16
+ const res = await next();
17
+ const data = await res
18
+ .clone()
19
+ .json()
20
+ .catch(() => null);
21
+ if (data) cache.set(ctx.url, { data, expires: now + ttl });
22
+ return res;
23
+ }
24
+ return next();
25
+ };
26
+ };
@@ -0,0 +1,19 @@
1
+ import { Middleware } from "../types";
2
+
3
+ export type LoggingOptions = {
4
+ logRequest?: boolean;
5
+ logResponse?: boolean;
6
+ debug?: boolean;
7
+ };
8
+
9
+ export const loggingMiddleware: Middleware<LoggingOptions> = async (ctx, next, options) => {
10
+ const { logRequest = true, logResponse = true, debug = true } = options || {};
11
+
12
+ if (debug && logRequest) console.log("➡️ Request:", ctx.url, ctx.init);
13
+
14
+ const res = await next();
15
+
16
+ if (debug && logResponse) console.log("⬅️ Response:", res.status);
17
+
18
+ return res;
19
+ };
@@ -0,0 +1,25 @@
1
+ import { Middleware } from "../types";
2
+
3
+ export type RetryOptions = {
4
+ maxRetries?: number;
5
+ delay?: number; // ms
6
+ };
7
+
8
+ export const retryMiddleware = (options?: RetryOptions): Middleware => {
9
+ const { maxRetries = 3, delay = 500 } = options || {};
10
+
11
+ const middleware: Middleware = async (ctx, next) => {
12
+ let attempt = 0;
13
+ while (true) {
14
+ try {
15
+ return await next();
16
+ } catch (err) {
17
+ if (attempt >= maxRetries) throw err;
18
+ attempt++;
19
+ await new Promise(r => setTimeout(r, delay * 2 ** attempt));
20
+ }
21
+ }
22
+ };
23
+
24
+ return middleware;
25
+ };
package/src/types.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+
3
+ export type EndpointDef<
4
+ TReq extends z.ZodTypeAny,
5
+ TRes extends z.ZodTypeAny
6
+ > = {
7
+ method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
8
+ path: string;
9
+ auth?: boolean;
10
+ request: TReq;
11
+ response: TRes;
12
+ };
13
+
14
+ export type Contracts = {
15
+ [ModuleName: string]: {
16
+ [EndpointName: string]: EndpointDef<z.ZodTypeAny, z.ZodTypeAny>;
17
+ };
18
+ };
19
+
20
+ export interface MiddlewareContext {
21
+ url: string;
22
+ init: RequestInit;
23
+ }
24
+
25
+ export type MiddlewareNext = () => Promise<Response>;
26
+
27
+ export type Middleware<Options = any> = (
28
+ ctx: MiddlewareContext,
29
+ next: MiddlewareNext,
30
+ options?: Options
31
+ ) => Promise<Response>;
32
+
33
+ export type ErrorLike = {
34
+ message: string;
35
+ status?: number;
36
+ code?: string;
37
+ [key: string]: any;
38
+ };
39
+
40
+ export type EndpointDefZ = EndpointDef<z.ZodTypeAny, z.ZodTypeAny>;
41
+
42
+ export type EndpointMethods<M extends Record<string, EndpointDefZ>> = {
43
+ [K in keyof M]: (
44
+ input: z.infer<M[K]["request"]>
45
+ ) => Promise<z.infer<M[K]["response"]>>;
46
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "declaration": true,
7
+ "outDir": "dist",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "forceConsistentCasingInFileNames": true
11
+ },
12
+ "include": ["src", "__test__"]
13
+ }