@hashrytech/quick-components-kit 0.11.1 → 0.12.1
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/CHANGELOG.md +12 -0
- package/dist/{checkbox → components/checkbox}/Checkbox.svelte.d.ts +1 -1
- package/dist/{drawer → components/drawer}/Drawer.svelte +1 -1
- package/dist/{modal → components/modal}/Modal.svelte +2 -2
- package/dist/{overlay → components/overlay}/Overlay.svelte +2 -2
- package/dist/index.d.ts +13 -7
- package/dist/index.js +13 -7
- package/dist/modules/api-client.d.ts +253 -0
- package/dist/modules/api-client.js +464 -0
- package/dist/modules/api-proxy.d.ts +20 -0
- package/dist/modules/api-proxy.js +58 -0
- package/dist/modules/crypto.d.ts +27 -0
- package/dist/modules/crypto.js +76 -0
- package/dist/modules/problem-details.d.ts +31 -0
- package/dist/modules/problem-details.js +40 -0
- package/package.json +1 -1
- /package/dist/{button → components/button}/Button.svelte +0 -0
- /package/dist/{button → components/button}/Button.svelte.d.ts +0 -0
- /package/dist/{button → components/button}/index.d.ts +0 -0
- /package/dist/{button → components/button}/index.js +0 -0
- /package/dist/{checkbox → components/checkbox}/Checkbox.svelte +0 -0
- /package/dist/{checkbox → components/checkbox}/index.d.ts +0 -0
- /package/dist/{checkbox → components/checkbox}/index.js +0 -0
- /package/dist/{drawer → components/drawer}/Drawer.svelte.d.ts +0 -0
- /package/dist/{drawer → components/drawer}/index.d.ts +0 -0
- /package/dist/{drawer → components/drawer}/index.js +0 -0
- /package/dist/{hamburger-menu → components/hamburger-menu}/HamburgerMenu.svelte +0 -0
- /package/dist/{hamburger-menu → components/hamburger-menu}/HamburgerMenu.svelte.d.ts +0 -0
- /package/dist/{hamburger-menu → components/hamburger-menu}/index.d.ts +0 -0
- /package/dist/{hamburger-menu → components/hamburger-menu}/index.js +0 -0
- /package/dist/{link-button → components/link-button}/LinkButton.svelte +0 -0
- /package/dist/{link-button → components/link-button}/LinkButton.svelte.d.ts +0 -0
- /package/dist/{link-button → components/link-button}/index.d.ts +0 -0
- /package/dist/{link-button → components/link-button}/index.js +0 -0
- /package/dist/{modal → components/modal}/Modal.svelte.d.ts +0 -0
- /package/dist/{modal → components/modal}/index.d.ts +0 -0
- /package/dist/{modal → components/modal}/index.js +0 -0
- /package/dist/{overlay → components/overlay}/Overlay.svelte.d.ts +0 -0
- /package/dist/{overlay → components/overlay}/index.d.ts +0 -0
- /package/dist/{overlay → components/overlay}/index.js +0 -0
- /package/dist/{radio → components/radio}/Radio.svelte +0 -0
- /package/dist/{radio → components/radio}/Radio.svelte.d.ts +0 -0
- /package/dist/{radio → components/radio}/index.d.ts +0 -0
- /package/dist/{radio → components/radio}/index.js +0 -0
- /package/dist/{text-input → components/text-input}/TextInput.svelte +0 -0
- /package/dist/{text-input → components/text-input}/TextInput.svelte.d.ts +0 -0
- /package/dist/{text-input → components/text-input}/index.d.ts +0 -0
- /package/dist/{text-input → components/text-input}/index.js +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @hashrytech/quick-components-kit
|
|
2
2
|
|
|
3
|
+
## 0.12.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- refactor: Using a typed wrapper approach for API Client
|
|
8
|
+
|
|
9
|
+
## 0.12.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- feat: Adding api-client, api-proxy, crypto and problem-details modules
|
|
14
|
+
|
|
3
15
|
## 0.11.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -15,6 +15,6 @@ export type CheckBoxProps = {
|
|
|
15
15
|
labelClass?: ClassNameValue;
|
|
16
16
|
class?: ClassNameValue;
|
|
17
17
|
};
|
|
18
|
-
declare const Checkbox: import("svelte").Component<CheckBoxProps, {}, "disabled" | "
|
|
18
|
+
declare const Checkbox: import("svelte").Component<CheckBoxProps, {}, "disabled" | "group" | "checked">;
|
|
19
19
|
type Checkbox = ReturnType<typeof Checkbox>;
|
|
20
20
|
export default Checkbox;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { fly } from 'svelte/transition';
|
|
5
5
|
import {twMerge} from 'tailwind-merge';
|
|
6
6
|
import Overlay from '../overlay/Overlay.svelte';
|
|
7
|
-
import { onKeydown } from '
|
|
7
|
+
import { onKeydown } from '../../actions/on-keydown.js';
|
|
8
8
|
|
|
9
9
|
export type DrawerProps = {
|
|
10
10
|
open?: boolean;
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
import { fade } from 'svelte/transition';
|
|
5
5
|
import {twMerge} from 'tailwind-merge';
|
|
6
6
|
import { browser } from '$app/environment';
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
import Overlay from '../overlay/Overlay.svelte';
|
|
8
|
+
import { onKeydown } from '../../actions/on-keydown.js';
|
|
9
9
|
|
|
10
10
|
export type ModalProps = {
|
|
11
11
|
open?: boolean;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
|
-
import {
|
|
2
|
+
import { type Snippet } from 'svelte';
|
|
3
3
|
import type { ClassNameValue } from 'tailwind-merge';
|
|
4
4
|
import { fade } from 'svelte/transition';
|
|
5
5
|
import {twMerge} from 'tailwind-merge';
|
|
6
|
-
import { disableScroll } from '
|
|
6
|
+
import { disableScroll } from '../../actions/disable-scroll.js';
|
|
7
7
|
|
|
8
8
|
export type OverlayProps = {
|
|
9
9
|
disableBodyScroll?: boolean;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
export * from './text-input/index.js';
|
|
2
|
-
export * from './button/index.js';
|
|
3
|
-
export * from './link-button/index.js';
|
|
4
|
-
export * from './hamburger-menu/index.js';
|
|
5
|
-
export * from './drawer/index.js';
|
|
6
|
-
export * from './modal/index.js';
|
|
7
|
-
export * from './overlay/index.js';
|
|
1
|
+
export * from './components/text-input/index.js';
|
|
2
|
+
export * from './components/button/index.js';
|
|
3
|
+
export * from './components/link-button/index.js';
|
|
4
|
+
export * from './components/hamburger-menu/index.js';
|
|
5
|
+
export * from './components/drawer/index.js';
|
|
6
|
+
export * from './components/modal/index.js';
|
|
7
|
+
export * from './components/overlay/index.js';
|
|
8
|
+
export * from './components/radio/index.js';
|
|
9
|
+
export * from './components/checkbox/index.js';
|
|
8
10
|
export * from './actions/disable-scroll.js';
|
|
9
11
|
export * from './actions/on-keydown.js';
|
|
10
12
|
export * from './actions/lock-scroll.js';
|
|
11
13
|
export * from './actions/scroll-to.js';
|
|
14
|
+
export * from './modules/api-client.js';
|
|
15
|
+
export * from './modules/api-proxy.js';
|
|
16
|
+
export * from './modules/crypto.js';
|
|
17
|
+
export * from './modules/problem-details.js';
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
// Reexport your entry components here
|
|
2
2
|
// lib/index.js
|
|
3
|
-
export * from './text-input/index.js';
|
|
4
|
-
export * from './button/index.js';
|
|
5
|
-
export * from './link-button/index.js';
|
|
6
|
-
export * from './hamburger-menu/index.js';
|
|
7
|
-
export * from './drawer/index.js';
|
|
8
|
-
export * from './modal/index.js';
|
|
9
|
-
export * from './overlay/index.js';
|
|
3
|
+
export * from './components/text-input/index.js';
|
|
4
|
+
export * from './components/button/index.js';
|
|
5
|
+
export * from './components/link-button/index.js';
|
|
6
|
+
export * from './components/hamburger-menu/index.js';
|
|
7
|
+
export * from './components/drawer/index.js';
|
|
8
|
+
export * from './components/modal/index.js';
|
|
9
|
+
export * from './components/overlay/index.js';
|
|
10
|
+
export * from './components/radio/index.js';
|
|
11
|
+
export * from './components/checkbox/index.js';
|
|
10
12
|
export * from './actions/disable-scroll.js';
|
|
11
13
|
export * from './actions/on-keydown.js';
|
|
12
14
|
export * from './actions/lock-scroll.js';
|
|
13
15
|
export * from './actions/scroll-to.js';
|
|
16
|
+
export * from './modules/api-client.js';
|
|
17
|
+
export * from './modules/api-proxy.js';
|
|
18
|
+
export * from './modules/crypto.js';
|
|
19
|
+
export * from './modules/problem-details.js';
|
|
14
20
|
// Add more components here...
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file This module defines a generic REST API client for SvelteKit applications.
|
|
3
|
+
* It provides methods for standard HTTP operations (GET, POST, PUT, PATCH, DELETE),
|
|
4
|
+
* supports authentication via Bearer tokens, includes customizable middleware hooks
|
|
5
|
+
* for request and response processing, and handles file uploads and downloads.
|
|
6
|
+
*
|
|
7
|
+
* It's designed to be used with SvelteKit's `fetch` (which is global in load functions
|
|
8
|
+
* and API routes) to automatically benefit from its enhancements (e.g., cookie forwarding,
|
|
9
|
+
* built-in request/response interception via SvelteKit's `handleFetch` hook).
|
|
10
|
+
*/
|
|
11
|
+
export type ApiResponse<T> = {
|
|
12
|
+
ok: boolean;
|
|
13
|
+
status: number;
|
|
14
|
+
data?: T;
|
|
15
|
+
error?: string;
|
|
16
|
+
};
|
|
17
|
+
export interface ApiClientConfig {
|
|
18
|
+
/** The base URL for your API (e.g., 'https://api.yourapi.com/v1'). */
|
|
19
|
+
baseURL: string;
|
|
20
|
+
/** Default headers to be sent with every request. */
|
|
21
|
+
defaultHeaders?: HeadersInit;
|
|
22
|
+
/** A function that returns the current access token. This is useful for client-side
|
|
23
|
+
* operations where you might store the token in a Svelte store or similar.
|
|
24
|
+
* For server-side operations using SvelteKit's `fetch`, the token is typically
|
|
25
|
+
* handled by `src/hooks.server.ts` via `handleFetch`.
|
|
26
|
+
*/
|
|
27
|
+
getAccessToken?: () => string | undefined;
|
|
28
|
+
}
|
|
29
|
+
export interface RequestOptions extends RequestInit {
|
|
30
|
+
/** If true, the Authorization header will not be added to this request. */
|
|
31
|
+
skipAuth?: boolean;
|
|
32
|
+
/** Specifies how the response body should be parsed.
|
|
33
|
+
* 'json' (default): Parses as JSON.
|
|
34
|
+
* 'text': Parses as plain text.
|
|
35
|
+
* 'blob': Parses as a Blob (useful for file downloads).
|
|
36
|
+
* 'arrayBuffer': Parses as an ArrayBuffer.
|
|
37
|
+
* 'raw': Returns the raw Response object without parsing the body.
|
|
38
|
+
*/
|
|
39
|
+
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer' | 'raw';
|
|
40
|
+
/**
|
|
41
|
+
* The `fetch` function to use for making the request.
|
|
42
|
+
* In SvelteKit:
|
|
43
|
+
* - In `+page.server.ts`, `+layout.server.ts`, `+server.ts` it's the enhanced `fetch` passed to `load` functions/handlers.
|
|
44
|
+
* - In `+page.svelte`, `+layout.svelte`, `+client.ts` it's the browser's global `fetch`.
|
|
45
|
+
*/
|
|
46
|
+
fetchInstance?: typeof fetch;
|
|
47
|
+
}
|
|
48
|
+
export type RequestInterceptor = (request: Request) => Promise<Request> | Request;
|
|
49
|
+
export type ResponseInterceptor = (response: Response) => Promise<Response> | Response;
|
|
50
|
+
export type ErrorHandler = (error: Error) => Promise<void> | void;
|
|
51
|
+
/**
|
|
52
|
+
* Custom error class for API responses.
|
|
53
|
+
* Provides access to the HTTP status code.
|
|
54
|
+
*/
|
|
55
|
+
export declare class ApiError extends Error {
|
|
56
|
+
status: number;
|
|
57
|
+
constructor(message: string, status: number);
|
|
58
|
+
}
|
|
59
|
+
export interface ApiClientEvents {
|
|
60
|
+
onRequest?: (request: Request) => void;
|
|
61
|
+
onResponse?: (response: Response) => void;
|
|
62
|
+
onError?: (error: Error) => void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A generic REST API client.
|
|
66
|
+
*
|
|
67
|
+
* Features:
|
|
68
|
+
* - Configurable base URL and default headers.
|
|
69
|
+
* - Supports GET, POST, PUT, PATCH, DELETE methods.
|
|
70
|
+
* - Handles Bearer token authentication.
|
|
71
|
+
* - Customizable request and response interceptors (middleware hooks).
|
|
72
|
+
* - Centralized error handling.
|
|
73
|
+
* - Supports file uploads (FormData).
|
|
74
|
+
* - Supports file downloads (returns Blob).
|
|
75
|
+
* - Integrates with SvelteKit's `fetch` for server-side benefits.
|
|
76
|
+
*/
|
|
77
|
+
export declare class ApiClient {
|
|
78
|
+
private baseURL;
|
|
79
|
+
private defaultHeaders;
|
|
80
|
+
private clientAuthToken;
|
|
81
|
+
private getAccessTokenFromStore;
|
|
82
|
+
private requestInterceptors;
|
|
83
|
+
private responseInterceptors;
|
|
84
|
+
private errorHandlers;
|
|
85
|
+
private events;
|
|
86
|
+
/**
|
|
87
|
+
* Creates an instance of ApiClient.
|
|
88
|
+
* @param config - Configuration for the API client.
|
|
89
|
+
*/
|
|
90
|
+
constructor(config: ApiClientConfig & {
|
|
91
|
+
events?: ApiClientEvents;
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* Sets the Bearer token for client-side requests.
|
|
95
|
+
* This will override `getAccessToken` for subsequent requests using this client instance.
|
|
96
|
+
* @param token - The access token string, or undefined to clear it.
|
|
97
|
+
*/
|
|
98
|
+
setAuthToken(token: string | undefined): void;
|
|
99
|
+
/**
|
|
100
|
+
* Adds a request interceptor. Interceptors can modify the `Request` object before it's sent.
|
|
101
|
+
* @param interceptor - A function that takes a `Request` object and returns a `Request` or a Promise resolving to a `Request`.
|
|
102
|
+
*/
|
|
103
|
+
addRequestInterceptor(interceptor: RequestInterceptor): void;
|
|
104
|
+
/**
|
|
105
|
+
* Adds a response interceptor. Interceptors can modify the `Response` object after it's received but before parsing.
|
|
106
|
+
* Useful for global error handling (e.g., refreshing tokens on 401).
|
|
107
|
+
* @param interceptor - A function that takes a `Response` object and returns a `Response` or a Promise resolving to a `Response`.
|
|
108
|
+
*/
|
|
109
|
+
addResponseInterceptor(interceptor: ResponseInterceptor): void;
|
|
110
|
+
/**
|
|
111
|
+
* Adds a global error handler. These handlers are called when a request fails (network error or non-2xx API response).
|
|
112
|
+
* @param handler - A function that takes an `Error` object.
|
|
113
|
+
*/
|
|
114
|
+
addErrorHandler(handler: ErrorHandler): void;
|
|
115
|
+
setEventHooks(events: Partial<ApiClientEvents>): void;
|
|
116
|
+
/**
|
|
117
|
+
* Processes the request, applying default headers, auth token, and request interceptors.
|
|
118
|
+
* @param endpoint - The API endpoint (e.g., '/products', '/users/123').
|
|
119
|
+
* @param method - HTTP method (GET, POST, etc.).
|
|
120
|
+
* @param body - The request body.
|
|
121
|
+
* @param options - Additional request options.
|
|
122
|
+
* @returns A processed `Request` object ready for `fetch`.
|
|
123
|
+
*/
|
|
124
|
+
private processRequest;
|
|
125
|
+
/**
|
|
126
|
+
* Processes the response, applying response interceptors and parsing the body.
|
|
127
|
+
* @param response - The raw `Response` object from `fetch`.
|
|
128
|
+
* @param options - The request options, used for `responseType`.
|
|
129
|
+
* @returns The parsed response data or the raw `Response` object.
|
|
130
|
+
* @throws `ApiError` if the response status is not OK (2xx).
|
|
131
|
+
*/
|
|
132
|
+
private processResponse;
|
|
133
|
+
/**
|
|
134
|
+
* Handles errors by invoking registered error handlers.
|
|
135
|
+
* @param error - The error that occurred during the request (type unknown to handle all potential errors).
|
|
136
|
+
* @throws The original error if no handler fully resolves it.
|
|
137
|
+
*/
|
|
138
|
+
private handleError;
|
|
139
|
+
/**
|
|
140
|
+
* Generic request method. All other HTTP methods call this.
|
|
141
|
+
* @param endpoint - The API endpoint.
|
|
142
|
+
* @param method - The HTTP method.
|
|
143
|
+
* @param body - The request body (optional). Can be BodyInit (string, Blob, FormData etc.) or a plain object (which will be JSON.stringified).
|
|
144
|
+
* @param options - Request options.
|
|
145
|
+
* @returns A Promise resolving to the parsed response data or raw Response/Blob.
|
|
146
|
+
* @template T - Expected type of the response data.
|
|
147
|
+
*/
|
|
148
|
+
request<T>(endpoint: string, method: string, body: BodyInit | object | null | undefined, options?: RequestOptions): Promise<ApiResponse<T>>;
|
|
149
|
+
/**
|
|
150
|
+
* Sends a GET request to the specified API endpoint.
|
|
151
|
+
*
|
|
152
|
+
* @param endpoint - The relative path (e.g., `/users` or `/orders/123`).
|
|
153
|
+
* @param options - Optional request configuration.
|
|
154
|
+
* @returns A Promise resolving to the parsed response or raw Response object.
|
|
155
|
+
* @template T - Expected shape of the JSON response.
|
|
156
|
+
*/
|
|
157
|
+
get<T>(endpoint: string, options?: RequestOptions): Promise<ApiResponse<T>>;
|
|
158
|
+
/**
|
|
159
|
+
* Sends a POST request to the specified API endpoint.
|
|
160
|
+
*
|
|
161
|
+
* @param endpoint - The relative path (e.g., `/products`).
|
|
162
|
+
* @param data - The request body (object, JSON, or FormData).
|
|
163
|
+
* @param options - Optional request configuration.
|
|
164
|
+
* @returns A Promise resolving to the parsed response or raw Response object.
|
|
165
|
+
* @template T - Expected shape of the JSON response.
|
|
166
|
+
*/
|
|
167
|
+
post<T>(endpoint: string, data?: BodyInit | object | null, options?: RequestOptions): Promise<ApiResponse<T>>;
|
|
168
|
+
/**
|
|
169
|
+
* Sends a PUT request to the specified API endpoint.
|
|
170
|
+
* Typically used for full resource replacement.
|
|
171
|
+
*
|
|
172
|
+
* @param endpoint - The relative path (e.g., `/products/123`).
|
|
173
|
+
* @param data - The request body (object, JSON, or FormData).
|
|
174
|
+
* @param options - Optional request configuration.
|
|
175
|
+
* @returns A Promise resolving to the parsed response or raw Response object.
|
|
176
|
+
* @template T - Expected shape of the JSON response.
|
|
177
|
+
*/
|
|
178
|
+
put<T>(endpoint: string, data?: BodyInit | object | null, options?: RequestOptions): Promise<ApiResponse<T>>;
|
|
179
|
+
/**
|
|
180
|
+
* Sends a PATCH request to the specified API endpoint.
|
|
181
|
+
* Typically used for partial updates.
|
|
182
|
+
*
|
|
183
|
+
* @param endpoint - The relative path (e.g., `/users/123`).
|
|
184
|
+
* @param data - The partial resource update (object or FormData).
|
|
185
|
+
* @param options - Optional request configuration.
|
|
186
|
+
* @returns A Promise resolving to the parsed response or raw Response object.
|
|
187
|
+
* @template T - Expected shape of the JSON response.
|
|
188
|
+
*/
|
|
189
|
+
patch<T>(endpoint: string, data?: BodyInit | object | null, options?: RequestOptions): Promise<ApiResponse<T>>;
|
|
190
|
+
/**
|
|
191
|
+
* Sends a DELETE request to the specified API endpoint.
|
|
192
|
+
*
|
|
193
|
+
* @param endpoint - The relative path (e.g., `/orders/456`).
|
|
194
|
+
* @param options - Optional request configuration.
|
|
195
|
+
* @returns A Promise resolving to the parsed response or raw Response object.
|
|
196
|
+
* @template T - Expected shape of the JSON response (if any).
|
|
197
|
+
*/
|
|
198
|
+
delete<T>(endpoint: string, options?: RequestOptions): Promise<ApiResponse<T>>;
|
|
199
|
+
/**
|
|
200
|
+
* Handles file uploads.
|
|
201
|
+
* @param endpoint - The API endpoint for file upload.
|
|
202
|
+
* @param file - The file to upload (File, Blob, or FormData). If Blob/File, it's wrapped in FormData.
|
|
203
|
+
* @param fieldName - The name of the form field for the file (default: 'file').
|
|
204
|
+
* @param options - Additional request options.
|
|
205
|
+
* @returns A Promise resolving to the parsed response data.
|
|
206
|
+
* @template T - Expected type of the response data after upload.
|
|
207
|
+
*/
|
|
208
|
+
uploadFile<T>(endpoint: string, file: File | Blob | FormData, fieldName?: string, options?: RequestOptions): Promise<ApiResponse<T>>;
|
|
209
|
+
/**
|
|
210
|
+
* Handles file downloads.
|
|
211
|
+
* @param endpoint - The API endpoint for file download.
|
|
212
|
+
* @param options - Additional request options.
|
|
213
|
+
* @returns A Promise resolving to a `Blob` containing the file data.
|
|
214
|
+
*/
|
|
215
|
+
downloadFile(endpoint: string, options?: RequestOptions): Promise<Blob>;
|
|
216
|
+
/**
|
|
217
|
+
* Uploads a file to the specified API endpoint using XMLHttpRequest to track upload progress.
|
|
218
|
+
*
|
|
219
|
+
* This method supports file uploads via `multipart/form-data`, reports progress using a callback,
|
|
220
|
+
* and handles authentication headers and additional request headers.
|
|
221
|
+
*
|
|
222
|
+
* @param endpoint - The API endpoint to upload the file to (relative to baseURL).
|
|
223
|
+
* @param file - The file or blob to upload.
|
|
224
|
+
* @param onProgress - A callback that receives percentage progress updates (0–100).
|
|
225
|
+
* @param fieldName - The name of the form field used for the file (default is 'file').
|
|
226
|
+
* @param options - Optional request settings (headers, skipAuth, etc.).
|
|
227
|
+
* @returns A Promise resolving to the typed response from the server.
|
|
228
|
+
* @template T - Expected shape of the server's JSON response.
|
|
229
|
+
*/
|
|
230
|
+
uploadFileWithProgress<T>(endpoint: string, file: File | Blob, onProgress: (percent: number) => void, fieldName?: string, options?: RequestOptions): Promise<T>;
|
|
231
|
+
/**
|
|
232
|
+
* Downloads a file from the specified endpoint while reporting progress.
|
|
233
|
+
*
|
|
234
|
+
* This method streams the response using a readable stream, reads it chunk by chunk,
|
|
235
|
+
* and accumulates the data into a Blob. During the download, it calculates and emits
|
|
236
|
+
* percentage progress updates if the `Content-Length` header is provided.
|
|
237
|
+
*
|
|
238
|
+
* @param endpoint - The API endpoint to download the file from.
|
|
239
|
+
* @param onProgress - Callback that receives progress updates (as a percent from 0 to 100).
|
|
240
|
+
* @param options - Optional request options including headers, credentials, etc.
|
|
241
|
+
* @returns A Promise that resolves to a Blob containing the downloaded file.
|
|
242
|
+
*/
|
|
243
|
+
downloadWithProgress(endpoint: string, onProgress: (percent: number) => void, options?: RequestOptions): Promise<Blob>;
|
|
244
|
+
/**
|
|
245
|
+
* Executes multiple request functions concurrently and infers their return types.
|
|
246
|
+
*
|
|
247
|
+
* @param requests - An array of functions that each return a typed Promise.
|
|
248
|
+
* @returns A Promise resolving to an array of results, with types preserved.
|
|
249
|
+
*/
|
|
250
|
+
batch<T extends ReadonlyArray<() => Promise<unknown>>>(requests: T): Promise<{
|
|
251
|
+
[K in keyof T]: T[K] extends () => Promise<infer R> ? R : never;
|
|
252
|
+
}>;
|
|
253
|
+
}
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// src/lib/api/client.ts
|
|
2
|
+
/**
|
|
3
|
+
* Custom error class for API responses.
|
|
4
|
+
* Provides access to the HTTP status code.
|
|
5
|
+
*/
|
|
6
|
+
export class ApiError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
constructor(message, status) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'ApiError';
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A generic REST API client.
|
|
16
|
+
*
|
|
17
|
+
* Features:
|
|
18
|
+
* - Configurable base URL and default headers.
|
|
19
|
+
* - Supports GET, POST, PUT, PATCH, DELETE methods.
|
|
20
|
+
* - Handles Bearer token authentication.
|
|
21
|
+
* - Customizable request and response interceptors (middleware hooks).
|
|
22
|
+
* - Centralized error handling.
|
|
23
|
+
* - Supports file uploads (FormData).
|
|
24
|
+
* - Supports file downloads (returns Blob).
|
|
25
|
+
* - Integrates with SvelteKit's `fetch` for server-side benefits.
|
|
26
|
+
*/
|
|
27
|
+
export class ApiClient {
|
|
28
|
+
baseURL;
|
|
29
|
+
defaultHeaders;
|
|
30
|
+
// This token is primarily for explicit client-side setting if needed,
|
|
31
|
+
// otherwise relies on getAccessTokenFromStore or SvelteKit's `handleFetch`.
|
|
32
|
+
clientAuthToken;
|
|
33
|
+
getAccessTokenFromStore;
|
|
34
|
+
requestInterceptors = [];
|
|
35
|
+
responseInterceptors = [];
|
|
36
|
+
errorHandlers = [];
|
|
37
|
+
events = {};
|
|
38
|
+
/**
|
|
39
|
+
* Creates an instance of ApiClient.
|
|
40
|
+
* @param config - Configuration for the API client.
|
|
41
|
+
*/
|
|
42
|
+
constructor(config) {
|
|
43
|
+
this.baseURL = config.baseURL;
|
|
44
|
+
this.defaultHeaders = config.defaultHeaders || { 'Content-Type': 'application/json' };
|
|
45
|
+
this.getAccessTokenFromStore = config.getAccessToken;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Sets the Bearer token for client-side requests.
|
|
49
|
+
* This will override `getAccessToken` for subsequent requests using this client instance.
|
|
50
|
+
* @param token - The access token string, or undefined to clear it.
|
|
51
|
+
*/
|
|
52
|
+
setAuthToken(token) {
|
|
53
|
+
this.clientAuthToken = token;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Adds a request interceptor. Interceptors can modify the `Request` object before it's sent.
|
|
57
|
+
* @param interceptor - A function that takes a `Request` object and returns a `Request` or a Promise resolving to a `Request`.
|
|
58
|
+
*/
|
|
59
|
+
addRequestInterceptor(interceptor) {
|
|
60
|
+
this.requestInterceptors.push(interceptor);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Adds a response interceptor. Interceptors can modify the `Response` object after it's received but before parsing.
|
|
64
|
+
* Useful for global error handling (e.g., refreshing tokens on 401).
|
|
65
|
+
* @param interceptor - A function that takes a `Response` object and returns a `Response` or a Promise resolving to a `Response`.
|
|
66
|
+
*/
|
|
67
|
+
addResponseInterceptor(interceptor) {
|
|
68
|
+
this.responseInterceptors.push(interceptor);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Adds a global error handler. These handlers are called when a request fails (network error or non-2xx API response).
|
|
72
|
+
* @param handler - A function that takes an `Error` object.
|
|
73
|
+
*/
|
|
74
|
+
addErrorHandler(handler) {
|
|
75
|
+
this.errorHandlers.push(handler);
|
|
76
|
+
}
|
|
77
|
+
setEventHooks(events) {
|
|
78
|
+
Object.assign(this.events, events);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Processes the request, applying default headers, auth token, and request interceptors.
|
|
82
|
+
* @param endpoint - The API endpoint (e.g., '/products', '/users/123').
|
|
83
|
+
* @param method - HTTP method (GET, POST, etc.).
|
|
84
|
+
* @param body - The request body.
|
|
85
|
+
* @param options - Additional request options.
|
|
86
|
+
* @returns A processed `Request` object ready for `fetch`.
|
|
87
|
+
*/
|
|
88
|
+
async processRequest(endpoint, method, body, options) {
|
|
89
|
+
const url = new URL(endpoint, this.baseURL); // Resolve endpoint relative to baseURL
|
|
90
|
+
const requestHeaders = new Headers(this.defaultHeaders);
|
|
91
|
+
// Merge custom headers from options using Headers constructor for robustness
|
|
92
|
+
if (options.headers) {
|
|
93
|
+
// Create a temporary Headers object from options.headers to iterate consistently
|
|
94
|
+
const incomingHeaders = new Headers(options.headers);
|
|
95
|
+
for (const [key, value] of incomingHeaders.entries()) {
|
|
96
|
+
requestHeaders.set(key, value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Add auth token unless skipped
|
|
100
|
+
if (!options.skipAuth) {
|
|
101
|
+
// Priority: Explicit clientAuthToken > getAccessTokenFromStore
|
|
102
|
+
const token = this.clientAuthToken || (this.getAccessTokenFromStore ? this.getAccessTokenFromStore() : undefined);
|
|
103
|
+
if (token) {
|
|
104
|
+
requestHeaders.set('Authorization', `Bearer ${token}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let processedBody = undefined;
|
|
108
|
+
if (body instanceof FormData) {
|
|
109
|
+
requestHeaders.delete('Content-Type'); // Important: Let browser set Content-Type for FormData
|
|
110
|
+
processedBody = body;
|
|
111
|
+
}
|
|
112
|
+
else if (body !== undefined && body !== null) {
|
|
113
|
+
const contentType = requestHeaders.get('Content-Type');
|
|
114
|
+
// If Content-Type is JSON or it's a plain object (and not a Blob/File/Stream type of BodyInit), stringify it.
|
|
115
|
+
if (contentType?.includes('application/json') ||
|
|
116
|
+
(typeof body === 'object' &&
|
|
117
|
+
!(body instanceof Blob) &&
|
|
118
|
+
!(body instanceof ArrayBuffer) &&
|
|
119
|
+
!(body instanceof ReadableStream) && // Covers streams like Node.js Buffer
|
|
120
|
+
!(body instanceof URLSearchParams))) {
|
|
121
|
+
processedBody = JSON.stringify(body);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
// Otherwise, assume `body` is already a valid BodyInit (string, Blob, ArrayBuffer, etc.)
|
|
125
|
+
// A direct cast is used here as the prior checks narrow the type.
|
|
126
|
+
processedBody = body;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
let request = new Request(url.toString(), {
|
|
130
|
+
method: method,
|
|
131
|
+
headers: requestHeaders,
|
|
132
|
+
body: processedBody,
|
|
133
|
+
...options // Spread other fetch options like cache, credentials etc.
|
|
134
|
+
});
|
|
135
|
+
// Apply request interceptors sequentially
|
|
136
|
+
for (const interceptor of this.requestInterceptors) {
|
|
137
|
+
request = await Promise.resolve(interceptor(request));
|
|
138
|
+
}
|
|
139
|
+
return request;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Processes the response, applying response interceptors and parsing the body.
|
|
143
|
+
* @param response - The raw `Response` object from `fetch`.
|
|
144
|
+
* @param options - The request options, used for `responseType`.
|
|
145
|
+
* @returns The parsed response data or the raw `Response` object.
|
|
146
|
+
* @throws `ApiError` if the response status is not OK (2xx).
|
|
147
|
+
*/
|
|
148
|
+
async processResponse(response, options) {
|
|
149
|
+
// Apply response interceptors sequentially
|
|
150
|
+
for (const interceptor of this.responseInterceptors) {
|
|
151
|
+
response = await Promise.resolve(interceptor(response));
|
|
152
|
+
}
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
let errorMessage = response.statusText;
|
|
155
|
+
let errorJson;
|
|
156
|
+
// Clone the response before reading body for error parsing,
|
|
157
|
+
// so original response body is still available if responseType is 'raw'.
|
|
158
|
+
const errorResponseClone = response.clone();
|
|
159
|
+
try {
|
|
160
|
+
// Attempt to parse a more descriptive error message from JSON response
|
|
161
|
+
errorJson = await errorResponseClone.json();
|
|
162
|
+
errorMessage = errorJson.message || errorJson.error || JSON.stringify(errorJson);
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
// If response is not JSON, use default status text or log parsing error
|
|
166
|
+
console.warn('API Client: Failed to parse error response as JSON.', e);
|
|
167
|
+
}
|
|
168
|
+
throw new ApiError(errorMessage, response.status);
|
|
169
|
+
}
|
|
170
|
+
switch (options.responseType) {
|
|
171
|
+
case 'text':
|
|
172
|
+
return (await response.text());
|
|
173
|
+
case 'blob':
|
|
174
|
+
return (await response.blob());
|
|
175
|
+
case 'arrayBuffer':
|
|
176
|
+
return (await response.arrayBuffer());
|
|
177
|
+
case 'raw': return response;
|
|
178
|
+
case 'json':
|
|
179
|
+
default:
|
|
180
|
+
// Handle 204 No Content (or other non-body responses)
|
|
181
|
+
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
|
|
182
|
+
return {}; // Return an empty object for no content
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
return (await response.json());
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new Error('Failed to parse response as JSON. Response was OK, but not valid JSON.');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Handles errors by invoking registered error handlers.
|
|
194
|
+
* @param error - The error that occurred during the request (type unknown to handle all potential errors).
|
|
195
|
+
* @throws The original error if no handler fully resolves it.
|
|
196
|
+
*/
|
|
197
|
+
async handleError(error) {
|
|
198
|
+
// Log the error by default
|
|
199
|
+
console.error("API Client encountered an error:", error);
|
|
200
|
+
// Cast to Error for common properties, or handle specifically
|
|
201
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
202
|
+
// Apply registered error handlers
|
|
203
|
+
for (const handler of this.errorHandlers) {
|
|
204
|
+
await Promise.resolve(handler(err)); // Pass the Error object
|
|
205
|
+
}
|
|
206
|
+
// Re-throw the error if it hasn't been "handled" by a handler (e.g., by redirecting)
|
|
207
|
+
// This ensures the calling context can still catch and react to the error.
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Generic request method. All other HTTP methods call this.
|
|
212
|
+
* @param endpoint - The API endpoint.
|
|
213
|
+
* @param method - The HTTP method.
|
|
214
|
+
* @param body - The request body (optional). Can be BodyInit (string, Blob, FormData etc.) or a plain object (which will be JSON.stringified).
|
|
215
|
+
* @param options - Request options.
|
|
216
|
+
* @returns A Promise resolving to the parsed response data or raw Response/Blob.
|
|
217
|
+
* @template T - Expected type of the response data.
|
|
218
|
+
*/
|
|
219
|
+
async request(endpoint, method, body, options = {}) {
|
|
220
|
+
const currentFetch = options.fetchInstance || fetch;
|
|
221
|
+
try {
|
|
222
|
+
const request = await this.processRequest(endpoint, method, body, options);
|
|
223
|
+
const response = await currentFetch(request);
|
|
224
|
+
const parsed = await this.processResponse(response, options);
|
|
225
|
+
return {
|
|
226
|
+
ok: true,
|
|
227
|
+
status: response.status,
|
|
228
|
+
data: parsed
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
await this.handleError(error);
|
|
233
|
+
const status = error instanceof ApiError ? error.status : 500;
|
|
234
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
status,
|
|
238
|
+
error: message
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// --- HTTP Method Shorthands ---
|
|
243
|
+
/**
|
|
244
|
+
* Sends a GET request to the specified API endpoint.
|
|
245
|
+
*
|
|
246
|
+
* @param endpoint - The relative path (e.g., `/users` or `/orders/123`).
|
|
247
|
+
* @param options - Optional request configuration.
|
|
248
|
+
* @returns A Promise resolving to the parsed response or raw Response object.
|
|
249
|
+
* @template T - Expected shape of the JSON response.
|
|
250
|
+
*/
|
|
251
|
+
get(endpoint, options) {
|
|
252
|
+
return this.request(endpoint, 'GET', undefined, options);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Sends a POST request to the specified API endpoint.
|
|
256
|
+
*
|
|
257
|
+
* @param endpoint - The relative path (e.g., `/products`).
|
|
258
|
+
* @param data - The request body (object, JSON, or FormData).
|
|
259
|
+
* @param options - Optional request configuration.
|
|
260
|
+
* @returns A Promise resolving to the parsed response or raw Response object.
|
|
261
|
+
* @template T - Expected shape of the JSON response.
|
|
262
|
+
*/
|
|
263
|
+
post(endpoint, data, options) {
|
|
264
|
+
return this.request(endpoint, 'POST', data, options);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Sends a PUT request to the specified API endpoint.
|
|
268
|
+
* Typically used for full resource replacement.
|
|
269
|
+
*
|
|
270
|
+
* @param endpoint - The relative path (e.g., `/products/123`).
|
|
271
|
+
* @param data - The request body (object, JSON, or FormData).
|
|
272
|
+
* @param options - Optional request configuration.
|
|
273
|
+
* @returns A Promise resolving to the parsed response or raw Response object.
|
|
274
|
+
* @template T - Expected shape of the JSON response.
|
|
275
|
+
*/
|
|
276
|
+
put(endpoint, data, options) {
|
|
277
|
+
return this.request(endpoint, 'PUT', data, options);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Sends a PATCH request to the specified API endpoint.
|
|
281
|
+
* Typically used for partial updates.
|
|
282
|
+
*
|
|
283
|
+
* @param endpoint - The relative path (e.g., `/users/123`).
|
|
284
|
+
* @param data - The partial resource update (object or FormData).
|
|
285
|
+
* @param options - Optional request configuration.
|
|
286
|
+
* @returns A Promise resolving to the parsed response or raw Response object.
|
|
287
|
+
* @template T - Expected shape of the JSON response.
|
|
288
|
+
*/
|
|
289
|
+
patch(endpoint, data, options) {
|
|
290
|
+
return this.request(endpoint, 'PATCH', data, options);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Sends a DELETE request to the specified API endpoint.
|
|
294
|
+
*
|
|
295
|
+
* @param endpoint - The relative path (e.g., `/orders/456`).
|
|
296
|
+
* @param options - Optional request configuration.
|
|
297
|
+
* @returns A Promise resolving to the parsed response or raw Response object.
|
|
298
|
+
* @template T - Expected shape of the JSON response (if any).
|
|
299
|
+
*/
|
|
300
|
+
delete(endpoint, options) {
|
|
301
|
+
// 'delete' is a reserved keyword, but acceptable as a method name in classes.
|
|
302
|
+
// If you encounter issues with specific environments, you could rename to 'del' or 'remove'.
|
|
303
|
+
return this.request(endpoint, 'DELETE', undefined, options);
|
|
304
|
+
}
|
|
305
|
+
// --- File Operations ---
|
|
306
|
+
/**
|
|
307
|
+
* Handles file uploads.
|
|
308
|
+
* @param endpoint - The API endpoint for file upload.
|
|
309
|
+
* @param file - The file to upload (File, Blob, or FormData). If Blob/File, it's wrapped in FormData.
|
|
310
|
+
* @param fieldName - The name of the form field for the file (default: 'file').
|
|
311
|
+
* @param options - Additional request options.
|
|
312
|
+
* @returns A Promise resolving to the parsed response data.
|
|
313
|
+
* @template T - Expected type of the response data after upload.
|
|
314
|
+
*/
|
|
315
|
+
uploadFile(endpoint, file, fieldName = 'file', options) {
|
|
316
|
+
let uploadBody;
|
|
317
|
+
if (file instanceof File || file instanceof Blob) {
|
|
318
|
+
uploadBody = new FormData();
|
|
319
|
+
uploadBody.append(fieldName, file);
|
|
320
|
+
}
|
|
321
|
+
else if (file instanceof FormData) {
|
|
322
|
+
uploadBody = file;
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
throw new Error('uploadFile expects a File, Blob, or FormData object.');
|
|
326
|
+
}
|
|
327
|
+
// Create new Headers based on provided options, and then delete 'Content-Type'.
|
|
328
|
+
// This ensures the browser sets the correct 'multipart/form-data' boundary.
|
|
329
|
+
const headers = new Headers(options?.headers);
|
|
330
|
+
headers.delete('Content-Type');
|
|
331
|
+
return this.request(endpoint, 'POST', uploadBody, { ...options, headers: headers });
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Handles file downloads.
|
|
335
|
+
* @param endpoint - The API endpoint for file download.
|
|
336
|
+
* @param options - Additional request options.
|
|
337
|
+
* @returns A Promise resolving to a `Blob` containing the file data.
|
|
338
|
+
*/
|
|
339
|
+
async downloadFile(endpoint, options) {
|
|
340
|
+
const response = await this.request(endpoint, 'GET', undefined, { ...options, responseType: 'blob' });
|
|
341
|
+
// Ensure the returned type is indeed a Blob before casting, for stricter type safety.
|
|
342
|
+
if (!(response instanceof Blob)) {
|
|
343
|
+
throw new Error('Expected Blob response for downloadFile, but got a different type or raw Response. Ensure responseType is explicitly set to "blob".');
|
|
344
|
+
}
|
|
345
|
+
return response;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Uploads a file to the specified API endpoint using XMLHttpRequest to track upload progress.
|
|
349
|
+
*
|
|
350
|
+
* This method supports file uploads via `multipart/form-data`, reports progress using a callback,
|
|
351
|
+
* and handles authentication headers and additional request headers.
|
|
352
|
+
*
|
|
353
|
+
* @param endpoint - The API endpoint to upload the file to (relative to baseURL).
|
|
354
|
+
* @param file - The file or blob to upload.
|
|
355
|
+
* @param onProgress - A callback that receives percentage progress updates (0–100).
|
|
356
|
+
* @param fieldName - The name of the form field used for the file (default is 'file').
|
|
357
|
+
* @param options - Optional request settings (headers, skipAuth, etc.).
|
|
358
|
+
* @returns A Promise resolving to the typed response from the server.
|
|
359
|
+
* @template T - Expected shape of the server's JSON response.
|
|
360
|
+
*/
|
|
361
|
+
async uploadFileWithProgress(endpoint, file, onProgress, fieldName = 'file', options = {}) {
|
|
362
|
+
const url = new URL(endpoint, this.baseURL).toString();
|
|
363
|
+
const formData = new FormData();
|
|
364
|
+
formData.append(fieldName, file);
|
|
365
|
+
const token = this.clientAuthToken || this.getAccessTokenFromStore?.();
|
|
366
|
+
const headers = new Headers(this.defaultHeaders);
|
|
367
|
+
// Merge user-supplied headers
|
|
368
|
+
if (options.headers) {
|
|
369
|
+
const userHeaders = new Headers(options.headers);
|
|
370
|
+
userHeaders.forEach((value, key) => headers.set(key, value));
|
|
371
|
+
}
|
|
372
|
+
return new Promise((resolve, reject) => {
|
|
373
|
+
const xhr = new XMLHttpRequest();
|
|
374
|
+
xhr.open('POST', url, true);
|
|
375
|
+
// Add Authorization if not skipped
|
|
376
|
+
if (!options.skipAuth && token) {
|
|
377
|
+
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
|
378
|
+
}
|
|
379
|
+
// Add any remaining headers
|
|
380
|
+
headers.forEach((value, key) => {
|
|
381
|
+
// Let the browser set Content-Type for FormData
|
|
382
|
+
if (key.toLowerCase() !== 'content-type') {
|
|
383
|
+
xhr.setRequestHeader(key, value);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
xhr.upload.onprogress = (event) => {
|
|
387
|
+
if (event.lengthComputable) {
|
|
388
|
+
onProgress(Math.round((event.loaded / event.total) * 100));
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
xhr.onload = () => {
|
|
392
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
393
|
+
const contentType = xhr.getResponseHeader('Content-Type') || '';
|
|
394
|
+
if (contentType.includes('application/json')) {
|
|
395
|
+
try {
|
|
396
|
+
const json = JSON.parse(xhr.responseText);
|
|
397
|
+
resolve(json);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
reject(new Error('Failed to parse JSON response'));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
// If not JSON, cast explicitly to unknown first, then to T
|
|
405
|
+
resolve(xhr.responseText);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
reject(new ApiError(xhr.statusText, xhr.status));
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
xhr.onerror = () => reject(new Error('Upload failed'));
|
|
413
|
+
xhr.send(formData);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Downloads a file from the specified endpoint while reporting progress.
|
|
418
|
+
*
|
|
419
|
+
* This method streams the response using a readable stream, reads it chunk by chunk,
|
|
420
|
+
* and accumulates the data into a Blob. During the download, it calculates and emits
|
|
421
|
+
* percentage progress updates if the `Content-Length` header is provided.
|
|
422
|
+
*
|
|
423
|
+
* @param endpoint - The API endpoint to download the file from.
|
|
424
|
+
* @param onProgress - Callback that receives progress updates (as a percent from 0 to 100).
|
|
425
|
+
* @param options - Optional request options including headers, credentials, etc.
|
|
426
|
+
* @returns A Promise that resolves to a Blob containing the downloaded file.
|
|
427
|
+
*/
|
|
428
|
+
async downloadWithProgress(endpoint, onProgress, options) {
|
|
429
|
+
const res = await this.request(endpoint, 'GET', undefined, { ...options, responseType: 'raw' });
|
|
430
|
+
if (!res.ok || !res.data) {
|
|
431
|
+
throw new Error(`Download failed: ${res.error ?? 'Unknown error'}`);
|
|
432
|
+
}
|
|
433
|
+
const response = res.data; // This is the raw Response object
|
|
434
|
+
const contentLengthHeader = response.headers.get('Content-Length');
|
|
435
|
+
const contentLength = contentLengthHeader ? parseInt(contentLengthHeader, 10) : 0;
|
|
436
|
+
const reader = response.body?.getReader();
|
|
437
|
+
if (!reader)
|
|
438
|
+
throw new Error('Failed to get reader from response body.');
|
|
439
|
+
const chunks = [];
|
|
440
|
+
let loaded = 0;
|
|
441
|
+
while (true) {
|
|
442
|
+
const { done, value } = await reader.read();
|
|
443
|
+
if (done)
|
|
444
|
+
break;
|
|
445
|
+
if (value) {
|
|
446
|
+
chunks.push(value);
|
|
447
|
+
loaded += value.length;
|
|
448
|
+
if (contentLength) {
|
|
449
|
+
onProgress(Math.round((loaded / contentLength) * 100));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return new Blob(chunks);
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Executes multiple request functions concurrently and infers their return types.
|
|
457
|
+
*
|
|
458
|
+
* @param requests - An array of functions that each return a typed Promise.
|
|
459
|
+
* @returns A Promise resolving to an array of results, with types preserved.
|
|
460
|
+
*/
|
|
461
|
+
batch(requests) {
|
|
462
|
+
return Promise.all(requests.map((req) => req()));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RequestHandler } from '@sveltejs/kit';
|
|
2
|
+
/**
|
|
3
|
+
* Configuration interface for the reusable proxy module.
|
|
4
|
+
*/
|
|
5
|
+
export interface ApiProxyConfig {
|
|
6
|
+
/** Your API base URL (e.g. 'https://api.example.com/v1') */
|
|
7
|
+
baseURL: string;
|
|
8
|
+
/** Allowed path prefixes for security */
|
|
9
|
+
allowedPaths: string[];
|
|
10
|
+
/** Optional extra headers to forward */
|
|
11
|
+
extraRequestHeaders?: string[];
|
|
12
|
+
/** Optional extra headers to return */
|
|
13
|
+
extraResponseHeaders?: string[];
|
|
14
|
+
/** Optional function to extract token from session */
|
|
15
|
+
extractToken?: (locals: App.Locals) => string | undefined;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Creates a set of SvelteKit request handlers for proxying API calls securely.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createApiProxyHandlers(config: ApiProxyConfig): Record<string, RequestHandler>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { error } from '@sveltejs/kit';
|
|
2
|
+
/**
|
|
3
|
+
* Creates a set of SvelteKit request handlers for proxying API calls securely.
|
|
4
|
+
*/
|
|
5
|
+
export function createApiProxyHandlers(config) {
|
|
6
|
+
const safeRequestHeaders = [
|
|
7
|
+
'authorization', 'content-type', 'accept', 'accept-language',
|
|
8
|
+
'cookie', 'x-csrftoken', 'referer', 'user-agent', 'x-requested-with',
|
|
9
|
+
...(config.extraRequestHeaders ?? [])
|
|
10
|
+
];
|
|
11
|
+
const safeResponseHeaders = [
|
|
12
|
+
'content-type', 'content-length', 'cache-control', 'etag',
|
|
13
|
+
...(config.extraResponseHeaders ?? [])
|
|
14
|
+
];
|
|
15
|
+
async function handler(event) {
|
|
16
|
+
const path = event.params.path;
|
|
17
|
+
const search = event.url.searchParams.toString();
|
|
18
|
+
const fullPath = `/${path}${search ? `?${search}` : ''}`;
|
|
19
|
+
const isAllowed = config.allowedPaths.some((prefix) => path?.startsWith(prefix));
|
|
20
|
+
if (!isAllowed)
|
|
21
|
+
throw error(403, 'Forbidden: This API path is not allowed.');
|
|
22
|
+
const url = `${config.baseURL}${fullPath}`;
|
|
23
|
+
const headers = new Headers();
|
|
24
|
+
for (const [key, value] of event.request.headers) {
|
|
25
|
+
if (safeRequestHeaders.includes(key.toLowerCase()) && value != null) {
|
|
26
|
+
headers.set(key, value);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const token = config.extractToken?.(event.locals);
|
|
30
|
+
if (token)
|
|
31
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
32
|
+
const proxyResponse = await fetch(url, {
|
|
33
|
+
method: event.request.method,
|
|
34
|
+
headers,
|
|
35
|
+
body: event.request.method !== 'GET' && event.request.method !== 'HEAD'
|
|
36
|
+
? await event.request.clone().arrayBuffer()
|
|
37
|
+
: undefined
|
|
38
|
+
});
|
|
39
|
+
const responseHeaders = new Headers();
|
|
40
|
+
for (const [key, value] of proxyResponse.headers) {
|
|
41
|
+
if (safeResponseHeaders.includes(key.toLowerCase()) && value != null) {
|
|
42
|
+
responseHeaders.set(key, value);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return new Response(proxyResponse.body, {
|
|
46
|
+
status: proxyResponse.status,
|
|
47
|
+
headers: responseHeaders
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
GET: handler,
|
|
52
|
+
POST: handler,
|
|
53
|
+
PUT: handler,
|
|
54
|
+
PATCH: handler,
|
|
55
|
+
DELETE: handler,
|
|
56
|
+
OPTIONS: handler
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypts plaintext using AES-256-GCM.
|
|
3
|
+
* Provides confidentiality, integrity, and authenticity.
|
|
4
|
+
*
|
|
5
|
+
* @param plaintext The string to encrypt.
|
|
6
|
+
* @param secretKey The 256-bit (32-byte) secret key, base64url encoded.
|
|
7
|
+
* @returns The encrypted data (IV + Tag + Ciphertext), base64url encoded.
|
|
8
|
+
*/
|
|
9
|
+
export declare function encrypt(plaintext: string, secretKey: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Decrypts data encrypted with AES-256-GCM.
|
|
12
|
+
* Verifies authenticity before decrypting.
|
|
13
|
+
*
|
|
14
|
+
* @param ivTagCiphertextB64 The encrypted data (IV + Tag + Ciphertext), base64url encoded.
|
|
15
|
+
* @param secretKey The 256-bit (32-byte) secret key, base64url encoded.
|
|
16
|
+
* @returns The decrypted plaintext string.
|
|
17
|
+
* @throws Error if authentication tag is invalid (data tampering) or decryption fails.
|
|
18
|
+
*/
|
|
19
|
+
export declare function decrypt(ivTagCiphertextB64: string, secretKey: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Generates random bytes and returns them as a base64url encoded string.
|
|
22
|
+
* Useful for generating cryptographic keys or nonces.
|
|
23
|
+
*
|
|
24
|
+
* @param length The number of random bytes to generate.
|
|
25
|
+
* @returns A base64url encoded string of random bytes.
|
|
26
|
+
*/
|
|
27
|
+
export declare function generateRandomBytes(length: number): string;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
// Command to generate a random 32-byte base64 key for AES-256 encryption
|
|
3
|
+
// node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
|
4
|
+
/**
|
|
5
|
+
* Encrypts plaintext using AES-256-GCM.
|
|
6
|
+
* Provides confidentiality, integrity, and authenticity.
|
|
7
|
+
*
|
|
8
|
+
* @param plaintext The string to encrypt.
|
|
9
|
+
* @param secretKey The 256-bit (32-byte) secret key, base64url encoded.
|
|
10
|
+
* @returns The encrypted data (IV + Tag + Ciphertext), base64url encoded.
|
|
11
|
+
*/
|
|
12
|
+
export function encrypt(plaintext, secretKey) {
|
|
13
|
+
const key = Buffer.from(secretKey, 'base64url'); // Ensure key is 32 bytes after decoding
|
|
14
|
+
if (key.length !== 32) {
|
|
15
|
+
throw new Error('Secret key must be 32 bytes (256 bits) for AES-256-GCM.');
|
|
16
|
+
}
|
|
17
|
+
const iv = crypto.randomBytes(16); // Initialization Vector (IV) for GCM is 16 bytes (128 bits)
|
|
18
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
19
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
20
|
+
const tag = cipher.getAuthTag(); // Authentication Tag
|
|
21
|
+
// Concatenate IV, Tag, and Ciphertext for storage/transmission
|
|
22
|
+
const combined = Buffer.concat([iv, tag, encrypted]);
|
|
23
|
+
return combined.toString('base64url');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Decrypts data encrypted with AES-256-GCM.
|
|
27
|
+
* Verifies authenticity before decrypting.
|
|
28
|
+
*
|
|
29
|
+
* @param ivTagCiphertextB64 The encrypted data (IV + Tag + Ciphertext), base64url encoded.
|
|
30
|
+
* @param secretKey The 256-bit (32-byte) secret key, base64url encoded.
|
|
31
|
+
* @returns The decrypted plaintext string.
|
|
32
|
+
* @throws Error if authentication tag is invalid (data tampering) or decryption fails.
|
|
33
|
+
*/
|
|
34
|
+
export function decrypt(ivTagCiphertextB64, secretKey) {
|
|
35
|
+
const key = Buffer.from(secretKey, 'base64url'); // Ensure key is 32 bytes after decoding
|
|
36
|
+
if (key.length !== 32) {
|
|
37
|
+
throw new Error('Secret key must be 32 bytes (256 bits) for AES-256-GCM.');
|
|
38
|
+
}
|
|
39
|
+
const combined = Buffer.from(ivTagCiphertextB64, 'base64url');
|
|
40
|
+
// Extract IV (16 bytes), Tag (16 bytes), and Ciphertext
|
|
41
|
+
const iv = combined.subarray(0, 16);
|
|
42
|
+
const tag = combined.subarray(16, 32);
|
|
43
|
+
const ciphertext = combined.subarray(32);
|
|
44
|
+
if (iv.length !== 16) {
|
|
45
|
+
throw new Error('Invalid IV length: Expected 16 bytes.');
|
|
46
|
+
}
|
|
47
|
+
if (tag.length !== 16) {
|
|
48
|
+
throw new Error('Invalid Auth Tag length: Expected 16 bytes.');
|
|
49
|
+
}
|
|
50
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
51
|
+
decipher.setAuthTag(tag); // Set the authentication tag for verification
|
|
52
|
+
try {
|
|
53
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
54
|
+
return decrypted.toString('utf-8');
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
// 'Bad decrypt' or 'Unsupported state or unable to authenticate data' indicates tampering or wrong key/IV/tag
|
|
58
|
+
if (e instanceof Error && (e.message.includes('Unsupported state') || e.message.includes('unable to authenticate data'))) {
|
|
59
|
+
throw new Error('Decryption failed: Data may have been tampered with or key is incorrect.');
|
|
60
|
+
}
|
|
61
|
+
throw e; // Re-throw other unexpected errors
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Generates random bytes and returns them as a base64url encoded string.
|
|
66
|
+
* Useful for generating cryptographic keys or nonces.
|
|
67
|
+
*
|
|
68
|
+
* @param length The number of random bytes to generate.
|
|
69
|
+
* @returns A base64url encoded string of random bytes.
|
|
70
|
+
*/
|
|
71
|
+
export function generateRandomBytes(length) {
|
|
72
|
+
if (length <= 0) {
|
|
73
|
+
throw new Error('Length must be a positive number.');
|
|
74
|
+
}
|
|
75
|
+
return crypto.randomBytes(length).toString('base64url');
|
|
76
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom error class representing a structured Problem Details error response.
|
|
3
|
+
*
|
|
4
|
+
* This class conforms to the RFC 7807 Problem Details format with an additional `errors`
|
|
5
|
+
* field for validation-specific field errors. It is suitable for use in APIs and form
|
|
6
|
+
* validation where you want to return field-specific messages along with standard
|
|
7
|
+
* HTTP error information.
|
|
8
|
+
*/
|
|
9
|
+
export declare class ProblemDetailError extends Error {
|
|
10
|
+
/** HTTP status code (e.g. 400, 404, 500) */
|
|
11
|
+
status: number;
|
|
12
|
+
/** A URI reference that identifies the problem type (e.g. "/exceptions/invalid_input/") */
|
|
13
|
+
type: string;
|
|
14
|
+
/** A human-readable explanation specific to this occurrence of the problem */
|
|
15
|
+
detail: string;
|
|
16
|
+
/**
|
|
17
|
+
* A dictionary of field-specific validation errors.
|
|
18
|
+
* Example: { email: ["Invalid email address"] }
|
|
19
|
+
*/
|
|
20
|
+
errors: Record<string, string[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Constructs a new ProblemDetailError instance.
|
|
23
|
+
*
|
|
24
|
+
* @param field - The field name that has a validation error.
|
|
25
|
+
* @param field_error - The error message associated with the field.
|
|
26
|
+
* @param status - HTTP status code (default: 400).
|
|
27
|
+
* @param title - A short, human-readable summary of the problem (default: "Bad Request").
|
|
28
|
+
* @param type - A URI identifier for the problem type (default: "/exceptions/bad_request/").
|
|
29
|
+
*/
|
|
30
|
+
constructor(field: string, field_error: string, status?: number, title?: string, type?: string);
|
|
31
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom error class representing a structured Problem Details error response.
|
|
3
|
+
*
|
|
4
|
+
* This class conforms to the RFC 7807 Problem Details format with an additional `errors`
|
|
5
|
+
* field for validation-specific field errors. It is suitable for use in APIs and form
|
|
6
|
+
* validation where you want to return field-specific messages along with standard
|
|
7
|
+
* HTTP error information.
|
|
8
|
+
*/
|
|
9
|
+
export class ProblemDetailError extends Error {
|
|
10
|
+
/** HTTP status code (e.g. 400, 404, 500) */
|
|
11
|
+
status;
|
|
12
|
+
/** A URI reference that identifies the problem type (e.g. "/exceptions/invalid_input/") */
|
|
13
|
+
type;
|
|
14
|
+
/** A human-readable explanation specific to this occurrence of the problem */
|
|
15
|
+
detail;
|
|
16
|
+
/**
|
|
17
|
+
* A dictionary of field-specific validation errors.
|
|
18
|
+
* Example: { email: ["Invalid email address"] }
|
|
19
|
+
*/
|
|
20
|
+
errors;
|
|
21
|
+
/**
|
|
22
|
+
* Constructs a new ProblemDetailError instance.
|
|
23
|
+
*
|
|
24
|
+
* @param field - The field name that has a validation error.
|
|
25
|
+
* @param field_error - The error message associated with the field.
|
|
26
|
+
* @param status - HTTP status code (default: 400).
|
|
27
|
+
* @param title - A short, human-readable summary of the problem (default: "Bad Request").
|
|
28
|
+
* @param type - A URI identifier for the problem type (default: "/exceptions/bad_request/").
|
|
29
|
+
*/
|
|
30
|
+
constructor(field, field_error, status = 400, title = "Bad Request", type = "/exceptions/bad_request/") {
|
|
31
|
+
super(title); // Call parent Error class constructor
|
|
32
|
+
this.name = "ProblemDetailError";
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.type = type;
|
|
35
|
+
this.detail = "Your request contained invalid input.";
|
|
36
|
+
this.errors = {
|
|
37
|
+
[field]: [field_error]
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|