@afoures/http-client 0.0.0 → 0.1.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.
@@ -0,0 +1,218 @@
1
+ import { AbortedError, NetworkError, TimeoutError, UnexpectedError } from "./errors.mjs";
2
+ import { StandardSchemaV1 } from "@standard-schema/spec";
3
+ import { Params } from "@remix-run/route-pattern";
4
+
5
+ //#region src/lib/types.d.ts
6
+ type Pretty<T> = { [K in keyof T]: T[K] } & {};
7
+ type is_any<T> = boolean extends (T extends never ? true : false) ? true : false;
8
+ type MaybePromise<T> = T | Promise<T>;
9
+ declare const ZeroWidthSpace = "\u200B";
10
+ /** Unrendered character (U+200B) used to mark a string type */
11
+ type ZeroWidthSpace = typeof ZeroWidthSpace;
12
+ type ErrorMessage<message extends string = string> = `error: ${message}${ZeroWidthSpace}`;
13
+ declare namespace Pathname {
14
+ type Relative = `/${string}`;
15
+ type WithParams = `${string}:${string}`;
16
+ type Params<pathname extends Pathname.Relative> = Pretty<{ [param in keyof Params<pathname>]: Params<pathname>[param] | number }>;
17
+ type DefaultParamsObjectSchema<pathname extends Pathname.Relative> = pathname extends Pathname.WithParams ? Schema._<Pathname.Params<pathname>> : never;
18
+ }
19
+ declare namespace HTTPStatus {
20
+ type InformationalResponse = 100 | 101 | 102 | 103;
21
+ type SuccessfulResponse = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226;
22
+ type RedirectMessage = 300 | 301 | 302 | 303 | 304 | 307 | 308;
23
+ type ClientErrorResponse = 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 421 | 422 | 423 | 424 | 425 | 426 | 428 | 429 | 431 | 451;
24
+ type ServerErrorResponse = 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511;
25
+ }
26
+ declare namespace HTTPMethod {
27
+ type WithBody = "POST" | "PUT" | "PATCH" | "DELETE";
28
+ type WithoutBody = "GET";
29
+ type Any = HTTPMethod.WithoutBody | HTTPMethod.WithBody;
30
+ }
31
+ type HeaderValue = string | number | boolean | null | undefined;
32
+ type HeaderReducer = (current_value: string | undefined) => string | undefined | null;
33
+ type HeadersInitWithReducer = [string, HeaderValue | HeaderReducer][] | Record<string, HeaderValue | HeaderReducer> | Headers;
34
+ declare namespace RetryPolicy {
35
+ type Condition = (context: {
36
+ request: Request;
37
+ response: Response | undefined;
38
+ error: UnexpectedError | NetworkError | TimeoutError | AbortedError | undefined;
39
+ }) => MaybePromise<boolean>;
40
+ type Attempts = number | ((context: {
41
+ request: Request;
42
+ }) => MaybePromise<number>);
43
+ type Delay = number | ((context: {
44
+ response: Response | undefined;
45
+ error: UnexpectedError | NetworkError | TimeoutError | AbortedError | undefined;
46
+ request: Request;
47
+ attempt: number;
48
+ }) => MaybePromise<number>);
49
+ type Configuration = {
50
+ /**
51
+ * the number of attempts to make before giving up
52
+ */
53
+ attempts?: Attempts;
54
+ /**
55
+ * the delay before retrying
56
+ */
57
+ delay?: Delay;
58
+ /**
59
+ * function to determine if a retry attempt should be made
60
+ */
61
+ when?: Condition;
62
+ };
63
+ }
64
+ declare namespace HTTPFetch {
65
+ type SharedResponseContent = {
66
+ headers: Headers;
67
+ raw_response: Response;
68
+ };
69
+ export type ClientErrorResponse<Error> = SharedResponseContent & {
70
+ ok: false;
71
+ status: HTTPStatus.ClientErrorResponse;
72
+ error: Error;
73
+ };
74
+ export type ServerErrorResponse<Error> = SharedResponseContent & {
75
+ ok: false;
76
+ status: HTTPStatus.ServerErrorResponse;
77
+ error: Error;
78
+ };
79
+ export type SuccessfulResponse<Data> = SharedResponseContent & {
80
+ ok: true;
81
+ } & ({
82
+ status: Exclude<HTTPStatus.SuccessfulResponse, 204>;
83
+ data: Data;
84
+ } | {
85
+ status: 204;
86
+ data: null;
87
+ });
88
+ export type RedirectMessage = SharedResponseContent & {
89
+ ok: false;
90
+ status: HTTPStatus.RedirectMessage;
91
+ redirect_to: string | null;
92
+ };
93
+ export type TypedParamsInit<pathname extends Pathname.Relative, params_schema extends Schema._> = is_any<params_schema> extends true ? {
94
+ params: any;
95
+ } : [params_schema] extends [never] ? pathname extends Pathname.WithParams ? {
96
+ params: Pathname.Params<pathname>;
97
+ } : {} : {
98
+ params: Schema.infer_input<params_schema>;
99
+ };
100
+ export type TypedQueryInit<query_schema extends Schema._> = is_any<query_schema> extends true ? {
101
+ query: any;
102
+ } : [query_schema] extends [never] ? {} : undefined extends Schema.infer_input<query_schema> ? {
103
+ query?: Schema.infer_input<query_schema>;
104
+ } : {
105
+ query: Schema.infer_input<query_schema>;
106
+ };
107
+ export type TypedBodyInit<body_schema extends Schema._> = is_any<body_schema> extends true ? {
108
+ body: any;
109
+ } : [body_schema] extends [never] ? {} : undefined extends Schema.infer_input<body_schema> ? {
110
+ body?: Schema.infer_input<body_schema>;
111
+ } : {
112
+ body: Schema.infer_input<body_schema>;
113
+ };
114
+ export type DefaultRequestInit = {
115
+ headers?: HeadersInitWithReducer;
116
+ } & Omit<RequestInit, "body" | "method" | "headers">;
117
+ export type OptionalRequestInit = {
118
+ /**
119
+ * timeout in milliseconds
120
+ */
121
+ timeout?: number;
122
+ /**
123
+ * retry policy
124
+ */
125
+ retry?: RetryPolicy.Configuration;
126
+ };
127
+ export {};
128
+ }
129
+ declare namespace Schema {
130
+ type _<input = unknown, output = input> = StandardSchemaV1<input, output>;
131
+ type Any = Schema._<any, any>;
132
+ type Unknown = Schema._<unknown, unknown>;
133
+ type infer_input<schema extends Schema.Any, default_value extends unknown = never> = [default_value] extends [never] ? StandardSchemaV1.InferInput<schema> : [schema] extends [never] ? default_value : StandardSchemaV1.InferInput<schema>;
134
+ type infer_output<schema extends Schema.Any, default_value extends unknown = never> = [default_value] extends [never] ? StandardSchemaV1.InferOutput<schema> : [schema] extends [never] ? default_value : StandardSchemaV1.InferOutput<schema>;
135
+ }
136
+ declare namespace Json {
137
+ /**
138
+ Matches a JSON object.
139
+ @category JSON
140
+ */
141
+ type Object = { [Key in string]: Json.Value };
142
+ /**
143
+ Matches a JSON array.
144
+ @category JSON
145
+ */
146
+ type Array = Json.Value[] | readonly Json.Value[];
147
+ /**
148
+ Matches any valid JSON primitive value.
149
+ @category JSON
150
+ */
151
+ type Primitive = string | number | boolean | null;
152
+ /**
153
+ Matches any valid JSON value.
154
+ @category JSON
155
+ */
156
+ type Value = Json.Primitive | Json.Object | Json.Array;
157
+ }
158
+ declare namespace Serializer {
159
+ type Any = {
160
+ schema: Schema.Any;
161
+ serialization?: string | ((data: any) => any);
162
+ };
163
+ type Params<pathname extends Pathname.Relative, schema extends Schema._> = schema extends Schema._<any, Pathname.Params<pathname>> ? {
164
+ schema: schema;
165
+ serialization?: (data: Schema.infer_output<NoInfer<schema>>) => Pathname.Params<pathname>;
166
+ } : {
167
+ schema: schema;
168
+ serialization: (data: Schema.infer_output<NoInfer<schema>>) => Pathname.Params<pathname>;
169
+ };
170
+ type QueryString<schema extends Schema._> = schema extends Schema._<any, Array<Array<string>> | Record<string, string> | undefined> ? {
171
+ schema: schema;
172
+ serialization?: "urlencoded" | ((data: Schema.infer_output<NoInfer<schema>>) => URLSearchParams);
173
+ } : {
174
+ schema: schema;
175
+ serialization: (data: Schema.infer_output<NoInfer<schema>>) => URLSearchParams;
176
+ };
177
+ type Body<schema extends Schema._> = schema extends Schema._<any, Json.Value> ? {
178
+ schema: schema;
179
+ serialization?: "json" | ((data: Schema.infer_output<NoInfer<schema>>) => {
180
+ body: BodyInit | null;
181
+ content_type: string;
182
+ });
183
+ } : {
184
+ schema: schema;
185
+ serialization: (data: Schema.infer_output<NoInfer<schema>>) => {
186
+ body: BodyInit | null;
187
+ content_type: string;
188
+ };
189
+ };
190
+ }
191
+ declare namespace Parser {
192
+ type Any = {
193
+ schema: Schema.Any;
194
+ deserialization?: string | ((data: any) => any);
195
+ };
196
+ type Data<schema extends Schema._> = schema extends Schema._<string, any> ? {
197
+ schema: schema;
198
+ deserialization: "text" | "json" | ((body: Response["body"]) => Promise<Schema.infer_input<NoInfer<schema>>>);
199
+ } : schema extends Schema._<Json.Value, any> ? {
200
+ schema: schema;
201
+ deserialization?: "json" | ((body: Response["body"]) => Promise<Schema.infer_input<NoInfer<schema>>>);
202
+ } : {
203
+ schema: schema;
204
+ deserialization: (body: Response["body"]) => Promise<Schema.infer_input<NoInfer<schema>>>;
205
+ };
206
+ type Error<schema extends Schema._> = schema extends Schema._<string, any> ? {
207
+ schema: schema;
208
+ deserialization: "text" | "json" | ((body: Response["body"]) => Promise<Schema.infer_input<NoInfer<schema>>>);
209
+ } : schema extends Schema._<Json.Value, any> ? {
210
+ schema: schema;
211
+ deserialization: "json" | ((body: Response["body"]) => Promise<Schema.infer_input<NoInfer<schema>>>);
212
+ } : {
213
+ schema: schema;
214
+ deserialization: (body: Response["body"]) => Promise<Schema.infer_input<NoInfer<schema>>>;
215
+ };
216
+ }
217
+ //#endregion
218
+ export { ErrorMessage, HTTPFetch, HTTPMethod, HTTPStatus, HeadersInitWithReducer, MaybePromise, Parser, Pathname, Pretty, Schema, Serializer };
@@ -0,0 +1,4 @@
1
+ import "@standard-schema/spec";
2
+ import "@remix-run/route-pattern";
3
+
4
+ export { };
@@ -0,0 +1,69 @@
1
+ //#region src/lib/utils.ts
2
+ function get_entries(source) {
3
+ if (source instanceof Headers) return source.entries();
4
+ if (Array.isArray(source)) return source;
5
+ return Object.entries(source);
6
+ }
7
+ function merge_headers(...sources) {
8
+ const headers = new Headers();
9
+ for (const source of sources) {
10
+ if (!source) continue;
11
+ for (const [raw_key, value_or_reducer] of get_entries(source)) {
12
+ const key = raw_key.toLowerCase();
13
+ if (typeof value_or_reducer === "function") {
14
+ const new_value = value_or_reducer(headers.get(key) ?? void 0);
15
+ if (new_value != null) headers.set(key, new_value);
16
+ else headers.delete(key);
17
+ } else if (value_or_reducer == null) headers.delete(key);
18
+ else headers.set(key, value_or_reducer.toString());
19
+ }
20
+ }
21
+ return headers;
22
+ }
23
+ function extract_args(input) {
24
+ const { params, query, body, ...rest } = input;
25
+ return {
26
+ options: rest,
27
+ args: {
28
+ params,
29
+ query,
30
+ body
31
+ }
32
+ };
33
+ }
34
+ function remove_custom_options(options) {
35
+ const { timeout: _timeout, headers: _headers, signal: _signal, retry: _retry, ...rest } = options;
36
+ return rest;
37
+ }
38
+ function merge_options(...sources) {
39
+ return {
40
+ ...sources.reduce((acc, source) => {
41
+ return {
42
+ ...acc,
43
+ ...source,
44
+ signal: acc.signal ? source.signal ? AbortSignal.any([acc.signal, source.signal]) : acc.signal : source.signal,
45
+ retry: {
46
+ ...acc.retry,
47
+ ...source.retry
48
+ }
49
+ };
50
+ }, {}),
51
+ headers: merge_headers(...sources.map((source) => source.headers))
52
+ };
53
+ }
54
+ function sleep(ms, signal) {
55
+ return new Promise((resolve, reject) => {
56
+ signal?.addEventListener("abort", on_abort, { once: true });
57
+ const token = setTimeout(() => {
58
+ signal?.removeEventListener("abort", on_abort);
59
+ resolve();
60
+ }, ms);
61
+ function on_abort() {
62
+ clearTimeout(token);
63
+ reject(signal.reason);
64
+ }
65
+ });
66
+ }
67
+
68
+ //#endregion
69
+ export { extract_args, merge_options, remove_custom_options, sleep };
@@ -0,0 +1,128 @@
1
+ # Endpoint Definition
2
+
3
+ The `Endpoint` class defines an HTTP endpoint with its method, path, serializers, and parsers.
4
+
5
+ ## Constructor
6
+
7
+ ```typescript
8
+ const endpoint = new Endpoint({
9
+ method: 'GET',
10
+ pathname: '/users/(:id)',
11
+ // ...options
12
+ })
13
+ ```
14
+
15
+ ## Options
16
+
17
+ ### `method` (required)
18
+
19
+ HTTP method for the endpoint:
20
+
21
+ ```typescript
22
+ type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
23
+ ```
24
+
25
+ - `GET` - Cannot have a body schema
26
+ - `POST`, `PUT`, `PATCH`, `DELETE` - Can have a body schema
27
+
28
+ ### `pathname` (required)
29
+
30
+ URL path with optional dynamic segments:
31
+
32
+ ```typescript
33
+ pathname: '/users' // Static path
34
+ pathname: '/users/:id' // Required param
35
+ pathname: '/users/(:id)' // Optional param
36
+ pathname: '/posts/:id/comments/:commentId' // Multiple params
37
+ ```
38
+
39
+ ### `params`
40
+
41
+ Serializer for path parameters. See [Serialization](./serialization.md#params).
42
+
43
+ ### `query`
44
+
45
+ Serializer for query string parameters. See [Serialization](./serialization.md#query).
46
+
47
+ ### `body`
48
+
49
+ Serializer for request body. See [Serialization](./serialization.md#body).
50
+
51
+ ### `data`
52
+
53
+ Parser for successful response body. See [Response Parsing](./response-parsing.md#data).
54
+
55
+ ### `error`
56
+
57
+ Parser for error response body. See [Response Parsing](./response-parsing.md#error).
58
+
59
+ ## Constructor
60
+
61
+ The `Endpoint` constructor takes a definition and optional default options:
62
+
63
+ ```typescript
64
+ const endpoint = new Endpoint({
65
+ method: 'GET',
66
+ pathname: '/users',
67
+ // Definition: method, pathname, params, query, body, data, error
68
+ }, {
69
+ headers: {
70
+ 'X-API-Version': '2',
71
+ },
72
+ timeout: 5000,
73
+ retry: {
74
+ attempts: 3,
75
+ delay: 1000,
76
+ when: ({ response }) => response?.status === 503,
77
+ },
78
+ })
79
+ ```
80
+
81
+ The second argument accepts:
82
+ - `headers`: Default headers for all requests
83
+ - `timeout`: Request timeout in milliseconds
84
+ - `retry`: Default retry policy
85
+
86
+ These can be overridden per-request.
87
+
88
+ See [Retry Policy](./retry-policy.md) for retry configuration.
89
+
90
+ ## Low-level Methods
91
+
92
+ Most users should use `http_client` instead of calling these methods directly. The HTTP client handles URL generation, body serialization, and response parsing automatically.
93
+
94
+ ### `generate_url(init)`
95
+
96
+ Generates a full URL with params and query serialized:
97
+
98
+ ```typescript
99
+ const url = await endpoint.generate_url({
100
+ origin: 'https://api.example.com',
101
+ params: { id: '123' },
102
+ query: { include: 'posts' },
103
+ })
104
+ ```
105
+
106
+ Returns `URL` on success or `SerializationError` on validation failure.
107
+
108
+ ### `serialize_body(init)`
109
+
110
+ Serializes the request body:
111
+
112
+ ```typescript
113
+ const { body, content_type } = await endpoint.serialize_body({
114
+ body: { name: 'John' },
115
+ })
116
+ ```
117
+
118
+ Returns `{ body, content_type }` on success or `SerializationError` on validation failure.
119
+
120
+ ### `parse_response(response)`
121
+
122
+ Parses an HTTP response:
123
+
124
+ ```typescript
125
+ const result = await endpoint.parse_response(response)
126
+ ```
127
+
128
+ Returns typed result based on status code. See [Response Parsing](./response-parsing.md).
@@ -0,0 +1,148 @@
1
+ # Error Handling
2
+
3
+ The HTTP client provides typed errors for different failure scenarios.
4
+
5
+ ## Error Types
6
+
7
+ ### `HttpClientError`
8
+
9
+ Base class for all HTTP client errors:
10
+
11
+ ```typescript
12
+ if (error instanceof HttpClientError) {
13
+ console.log(error.name) // "HttpClientError"
14
+ console.log(error.message) // Error message
15
+ console.log(error.context) // { operation: string }
16
+ }
17
+ ```
18
+
19
+ ### `TimeoutError`
20
+
21
+ Request exceeded the timeout:
22
+
23
+ ```typescript
24
+ const result = await api.users.get({ params: { id: '123' }, timeout: 1000 })
25
+
26
+ if (result instanceof TimeoutError) {
27
+ console.log(result.kind) // "TimeoutError"
28
+ console.log(result.context.operation) // "fetch"
29
+ }
30
+ ```
31
+
32
+ ### `AbortedError`
33
+
34
+ Request was aborted via `AbortSignal`:
35
+
36
+ ```typescript
37
+ const controller = new AbortController()
38
+ const result = await api.users.get({
39
+ params: { id: '123' },
40
+ signal: controller.signal,
41
+ })
42
+
43
+ if (result instanceof AbortedError) {
44
+ console.log(result.kind) // "AbortedError"
45
+ }
46
+ ```
47
+
48
+ ### `NetworkError`
49
+
50
+ Network-level failure (no response received):
51
+
52
+ ```typescript
53
+ const result = await api.users.get({ params: { id: '123' } })
54
+
55
+ if (result instanceof NetworkError) {
56
+ console.log(result.kind) // "NetworkError"
57
+ console.log(result.cause) // Underlying error
58
+ }
59
+ ```
60
+
61
+ ### `SerializationError`
62
+
63
+ Failed to serialize params, query, or body:
64
+
65
+ ```typescript
66
+ const result = await api.users.create({
67
+ body: { name: '' }, // Fails validation
68
+ })
69
+
70
+ if (result instanceof SerializationError) {
71
+ console.log(result.kind) // "SerializationError"
72
+ console.log(result.context.operation) // "serialize_body" | "generate_url"
73
+ console.log(result.cause) // Schema validation issues
74
+ }
75
+ ```
76
+
77
+ ### `DeserializationError`
78
+
79
+ Failed to parse response:
80
+
81
+ ```typescript
82
+ const result = await api.users.get({ params: { id: '123' } })
83
+
84
+ if (result instanceof DeserializationError) {
85
+ console.log(result.kind) // "DeserializationError"
86
+ console.log(result.cause) // Schema validation issues
87
+ }
88
+ ```
89
+
90
+ ### `UnexpectedError`
91
+
92
+ Unexpected failure during request:
93
+
94
+ ```typescript
95
+ const result = await api.users.get({ params: { id: '123' } })
96
+
97
+ if (result instanceof UnexpectedError) {
98
+ console.log(result.name) // "UnexpectedError"
99
+ console.log(result.context.operation) // "create_request" | "parse_response" | etc.
100
+ }
101
+ ```
102
+
103
+ ## Checking Results
104
+
105
+ ### Instance Check
106
+
107
+ ```typescript
108
+ const result = await api.users.get({ params: { id: '123' } })
109
+
110
+ if (result instanceof Error) {
111
+ // Handle all error types
112
+ if (result instanceof TimeoutError) {
113
+ // Retry or show timeout message
114
+ } else if (result instanceof NetworkError) {
115
+ // Show network error, maybe retry
116
+ }
117
+ return
118
+ }
119
+
120
+ // Handle successful response
121
+ if (result.ok) {
122
+ console.log(result.data)
123
+ }
124
+ ```
125
+
126
+ ## Error Context
127
+
128
+ All errors have a `context` property with the operation that failed:
129
+
130
+ ```typescript
131
+ if (result instanceof HttpClientError) {
132
+ result.context.operation
133
+ // "fetch" | "generate_url" | "serialize_body" | "parse_response" | "create_request" | "retry_policy" | "..."
134
+ }
135
+ ```
136
+
137
+ ## Raw Response
138
+
139
+ When available, the raw `Response` object is accessible:
140
+
141
+ ```typescript
142
+ const result = await api.users.get({ params: { id: '123' } })
143
+
144
+ if (!result.ok && !(result instanceof Error)) {
145
+ console.log(result.raw_response.status)
146
+ console.log(result.raw_response.headers)
147
+ }
148
+ ```