kayto_ts 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.
package/src/types.ts ADDED
@@ -0,0 +1,95 @@
1
+ export type EndpointsMap = {
2
+ get: Record<PropertyKey, unknown>;
3
+ post: Record<PropertyKey, unknown>;
4
+ put: Record<PropertyKey, unknown>;
5
+ patch: Record<PropertyKey, unknown>;
6
+ delete: Record<PropertyKey, unknown>;
7
+ };
8
+
9
+ export type EndpointOf<
10
+ Endpoints extends EndpointsMap,
11
+ Method extends keyof EndpointsMap,
12
+ Path extends keyof EndpointsMap[Method],
13
+ > = Endpoints[Method][Path];
14
+
15
+ export type EndpointParams<E> = E extends { params: infer P } ? P : never;
16
+ export type EndpointBody<E> = E extends { body: infer B } ? B : never;
17
+
18
+ export type ErrorKind =
19
+ | "network"
20
+ | "timeout"
21
+ | "aborted"
22
+ | "http"
23
+ | "parse"
24
+ | "hook";
25
+
26
+ export type ClientError = {
27
+ kind: ErrorKind;
28
+ message: string;
29
+ cause?: unknown;
30
+ status?: number;
31
+ };
32
+
33
+ export type FetchResult<R, E = ClientError> =
34
+ | { ok: true; result: R; response: Response }
35
+ | { ok: false; error: E; response?: Response };
36
+
37
+ export type Result<R, E = string> =
38
+ | { ok: true; result: R }
39
+ | { ok: false; error: E };
40
+
41
+ export type MaybePromise<T> = T | Promise<T>;
42
+
43
+ export type RequestOptions = {
44
+ signal?: AbortSignal;
45
+ timeoutMs?: number;
46
+ headers?: RequestInit["headers"];
47
+ };
48
+
49
+ export type RequestInput<E> = RequestOptions
50
+ & ([EndpointParams<E>] extends [never] ? {} : { params?: EndpointParams<E> })
51
+ & ([EndpointBody<E>] extends [never] ? {} : { body: EndpointBody<E> });
52
+
53
+ export type RequestArgs<E> = [EndpointBody<E>] extends [never]
54
+ ? [input?: RequestInput<E>]
55
+ : [input: RequestInput<E>];
56
+
57
+ export type Api<Endpoints extends EndpointsMap> = {
58
+ [Method in keyof EndpointsMap]: <
59
+ Path extends Extract<keyof Endpoints[Method], string>,
60
+ >(
61
+ path: Path,
62
+ ...args: RequestArgs<EndpointOf<Endpoints, Method, Path>>
63
+ ) => Promise<FetchResult<EndpointOf<Endpoints, Method, Path>>>;
64
+ };
65
+
66
+ export type RequestHookContext = {
67
+ method: keyof EndpointsMap;
68
+ path: string;
69
+ init: RequestInit;
70
+ };
71
+
72
+ export type ResponseHookContext = {
73
+ method: keyof EndpointsMap;
74
+ path: string;
75
+ response: Response;
76
+ durationMs: number;
77
+ };
78
+
79
+ export type ResponseInterceptorContext = {
80
+ method: keyof EndpointsMap;
81
+ path: string;
82
+ response: Response;
83
+ };
84
+
85
+ export type ClientHooks = {
86
+ onRequest?: (context: RequestHookContext) => MaybePromise<void>;
87
+ onResponse?: (context: ResponseHookContext) => MaybePromise<void>;
88
+ responseInterceptor?: (
89
+ context: ResponseInterceptorContext,
90
+ ) => MaybePromise<Response>;
91
+ };
92
+
93
+ export type ClientConfig = ClientHooks & {
94
+ baseUrl?: string;
95
+ };
package/src/utils.ts ADDED
@@ -0,0 +1,201 @@
1
+ import { type ClientError, type ErrorKind, type RequestInput, type RequestOptions, type Result } from "./types";
2
+
3
+ export const HTTP_METHOD = {
4
+ get: "GET",
5
+ post: "POST",
6
+ put: "PUT",
7
+ patch: "PATCH",
8
+ delete: "DELETE",
9
+ } as const;
10
+
11
+ export function makeClientError(
12
+ kind: ErrorKind,
13
+ message: string,
14
+ cause?: unknown,
15
+ status?: number,
16
+ ): ClientError {
17
+ return { kind, message, cause, status };
18
+ }
19
+
20
+ export function createFetchSignal(options: RequestOptions): {
21
+ signal?: AbortSignal;
22
+ cleanup: () => void;
23
+ didTimeout: () => boolean;
24
+ } {
25
+ const { signal, timeoutMs } = options;
26
+
27
+ if (timeoutMs == null) {
28
+ return { signal, cleanup: () => {}, didTimeout: () => false };
29
+ }
30
+
31
+ let timedOut = false;
32
+ const controller = new AbortController();
33
+ const onAbort = () => {
34
+ controller.abort(
35
+ signal?.reason ?? new DOMException("The operation was aborted", "AbortError"),
36
+ );
37
+ };
38
+
39
+ if (signal?.aborted) {
40
+ onAbort();
41
+ } else if (signal) {
42
+ signal.addEventListener("abort", onAbort, { once: true });
43
+ }
44
+
45
+ const timeoutId = setTimeout(() => {
46
+ timedOut = true;
47
+ controller.abort(
48
+ new DOMException(`Timed out after ${timeoutMs}ms`, "TimeoutError"),
49
+ );
50
+ }, timeoutMs);
51
+
52
+ const cleanup = () => {
53
+ clearTimeout(timeoutId);
54
+
55
+ if (signal) {
56
+ signal.removeEventListener("abort", onAbort);
57
+ }
58
+ };
59
+
60
+ return { signal: controller.signal, cleanup, didTimeout: () => timedOut };
61
+ }
62
+
63
+ function isRecord(value: unknown): value is Record<string, unknown> {
64
+ return typeof value === "object" && value !== null;
65
+ }
66
+
67
+ function isAbsoluteUrl(path: string): boolean {
68
+ return /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(path) || path.startsWith("//");
69
+ }
70
+
71
+ function appendQueryToUrl(path: string, query: Record<string, unknown>): string {
72
+ const absolute = isAbsoluteUrl(path);
73
+ const url = absolute ? new URL(path) : new URL(path, "http://localhost");
74
+
75
+ for (const [key, value] of Object.entries(query)) {
76
+ if (value == null) {
77
+ continue;
78
+ }
79
+
80
+ if (Array.isArray(value)) {
81
+ for (const item of value) {
82
+ if (item != null) {
83
+ url.searchParams.append(key, String(item));
84
+ }
85
+ }
86
+
87
+ continue;
88
+ }
89
+
90
+ url.searchParams.set(key, String(value));
91
+ }
92
+
93
+ if (absolute) {
94
+ return url.toString();
95
+ }
96
+
97
+ return `${url.pathname}${url.search}`;
98
+ }
99
+
100
+ export function buildPath(path: string, params: unknown): string {
101
+ if (!isRecord(params)) {
102
+ return path;
103
+ }
104
+
105
+ let nextPath = path;
106
+
107
+ if (isRecord(params.path)) {
108
+ for (const [key, value] of Object.entries(params.path)) {
109
+ if (value != null) {
110
+ nextPath = nextPath.replaceAll(
111
+ "{" + key + "}",
112
+ encodeURIComponent(String(value)),
113
+ );
114
+ }
115
+ }
116
+ }
117
+
118
+ if (isRecord(params.query)) {
119
+ return appendQueryToUrl(nextPath, params.query);
120
+ }
121
+
122
+ return nextPath;
123
+ }
124
+
125
+ export function resolveRequestUrl(path: string, baseUrl?: string): string {
126
+ if (!baseUrl || isAbsoluteUrl(path)) {
127
+ return path;
128
+ }
129
+
130
+ return new URL(path, baseUrl).toString();
131
+ }
132
+
133
+ function isRawBody(body: unknown): body is NonNullable<RequestInit["body"]> {
134
+ return (
135
+ typeof body === "string"
136
+ || body instanceof Blob
137
+ || body instanceof FormData
138
+ || body instanceof URLSearchParams
139
+ || body instanceof ArrayBuffer
140
+ || ArrayBuffer.isView(body)
141
+ || body instanceof ReadableStream
142
+ );
143
+ }
144
+
145
+ export function createBodyAndHeaders(input: RequestInput<unknown>): {
146
+ body?: NonNullable<RequestInit["body"]>;
147
+ headers: Headers;
148
+ } {
149
+ const headers = new Headers(input.headers);
150
+ const body = "body" in input ? (input as { body?: unknown }).body : undefined;
151
+
152
+ if (body == null) {
153
+ return { body: undefined, headers };
154
+ }
155
+
156
+ if (isRawBody(body)) {
157
+ return { body, headers };
158
+ }
159
+
160
+ if (!headers.has("content-type")) {
161
+ headers.set("content-type", "application/json");
162
+ }
163
+
164
+ return { body: JSON.stringify(body), headers };
165
+ }
166
+
167
+ function isJsonContentType(contentType: string): boolean {
168
+ return (
169
+ contentType.includes("application/json")
170
+ || contentType.includes("application/problem+json")
171
+ || contentType.includes("+json")
172
+ );
173
+ }
174
+
175
+ function isTextContentType(contentType: string): boolean {
176
+ return contentType.startsWith("text/");
177
+ }
178
+
179
+ export async function safeResponseBody(
180
+ response: Response,
181
+ ): Promise<Result<unknown, unknown>> {
182
+ const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
183
+
184
+ if (response.status === 204 || response.status === 205 || response.status === 304) {
185
+ return { ok: true, result: null };
186
+ }
187
+
188
+ try {
189
+ if (isJsonContentType(contentType)) {
190
+ return { ok: true, result: await response.json() };
191
+ }
192
+
193
+ if (isTextContentType(contentType)) {
194
+ return { ok: true, result: await response.text() };
195
+ }
196
+
197
+ return { ok: true, result: await response.blob() };
198
+ } catch (err) {
199
+ return { ok: false, error: err };
200
+ }
201
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }