@podosoft/podokit-api-client 0.8.4 → 0.9.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 +39 -2
- package/dist/index.d.ts +44 -11
- package/dist/index.js +67 -34
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,8 +6,9 @@ through this single entry point — never with a raw `fetch`.
|
|
|
6
6
|
- `client.auth` — the [better-auth](https://better-auth.com) client (email/password,
|
|
7
7
|
sessions, the `signupApproved` user field, and the admin plugin: `listUsers`,
|
|
8
8
|
`updateUser`, `banUser`, `setRole`, `listUserSessions`, …).
|
|
9
|
-
- `client.get/post/put/patch/del` — the app's
|
|
10
|
-
standard error envelope and throwing `ApiError` on failure.
|
|
9
|
+
- `client.get/post/put/patch/del` — the app's REST endpoints, parsing the
|
|
10
|
+
standard error envelope and throwing `ApiError` on failure. Bodies are JSON,
|
|
11
|
+
except `FormData` and binary bodies, which are sent as they are.
|
|
11
12
|
- `client.postForm` — multipart uploads with the browser-managed boundary and the
|
|
12
13
|
same credentials and error handling.
|
|
13
14
|
|
|
@@ -29,6 +30,40 @@ form.set("file", imageFile);
|
|
|
29
30
|
await api.postForm("/account/profile-image", form);
|
|
30
31
|
```
|
|
31
32
|
|
|
33
|
+
## Binary bodies and request options
|
|
34
|
+
|
|
35
|
+
`post`, `put`, and `patch` send a `Blob` (or `File`), an `ArrayBuffer`, a typed
|
|
36
|
+
array such as `Uint8Array`, or a `ReadableStream` as the raw request body.
|
|
37
|
+
Every call takes optional request options as its last argument:
|
|
38
|
+
|
|
39
|
+
| Option | Purpose |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `contentType` | Content type of the body. Binary bodies default to the Blob's type, else `application/octet-stream`; JSON bodies to `application/json`. |
|
|
42
|
+
| `headers` | Additional headers, such as `content-range` or an idempotency key. |
|
|
43
|
+
| `signal` | An `AbortSignal` that cancels the request. |
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// Upload a file in 8 MiB chunks, one request per chunk
|
|
47
|
+
const chunkSize = 8 * 1024 * 1024;
|
|
48
|
+
for (let part = 0; part * chunkSize < file.size; part++) {
|
|
49
|
+
const chunk = file.slice(part * chunkSize, (part + 1) * chunkSize);
|
|
50
|
+
await api.put(`/uploads/${id}/parts/${part + 1}`, chunk, {
|
|
51
|
+
contentType: "application/octet-stream",
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A `ReadableStream` body is sent with `duplex: "half"`. Browsers stream request
|
|
58
|
+
bodies only over HTTP/2 or later, and not every browser supports them, so send
|
|
59
|
+
`Blob` slices from the browser and keep streams for server-side code. The SvelteKit
|
|
60
|
+
`/api` proxy and the API each enforce their own request size limits.
|
|
61
|
+
|
|
62
|
+
`ApiError.retryAfterSeconds` holds the `Retry-After` delay of a failed response,
|
|
63
|
+
such as `429 RATE_LIMIT_EXCEEDED` or `503`, when the server sends one. Apps that
|
|
64
|
+
called `fetch` directly to send binary chunks or to read `Retry-After` can use
|
|
65
|
+
these instead.
|
|
66
|
+
|
|
32
67
|
## Options
|
|
33
68
|
|
|
34
69
|
| Option | Default | Purpose |
|
|
@@ -40,6 +75,8 @@ await api.postForm("/account/profile-image", form);
|
|
|
40
75
|
| `credentials` | `include` | Send cookies with requests. |
|
|
41
76
|
|
|
42
77
|
Errors are thrown as `ApiError` (`code`, `message`, `statusCode`, `details`).
|
|
78
|
+
A response without the JSON error envelope, such as a gateway's HTML error page,
|
|
79
|
+
becomes an `ApiError` with code `HTTP_ERROR` and the response status.
|
|
43
80
|
`PUBLIC_SIGNUP_DISABLED` and `SIGNUP_APPROVAL_REQUIRED` are re-exported for
|
|
44
81
|
provider-independent registration-policy handling.
|
|
45
82
|
The profile-image policy, response type, and stable validation error codes are
|
package/dist/index.d.ts
CHANGED
|
@@ -27,7 +27,28 @@ export declare class ApiError extends Error {
|
|
|
27
27
|
readonly code: string;
|
|
28
28
|
readonly statusCode: number;
|
|
29
29
|
readonly details?: unknown;
|
|
30
|
-
|
|
30
|
+
/** Seconds to wait before retrying, from a `Retry-After` header (for example on 429 or 503). */
|
|
31
|
+
readonly retryAfterSeconds?: number;
|
|
32
|
+
constructor(code: string, message: string, statusCode: number, details?: unknown, retryAfterSeconds?: number);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A request body sent as is rather than as JSON: a `Blob` or `File`, bytes, or
|
|
36
|
+
* a stream. Send large files as a `Blob` (or `blob.slice()` chunks); a stream
|
|
37
|
+
* body needs a server and browser that support streaming uploads.
|
|
38
|
+
*/
|
|
39
|
+
export type BinaryBody = Blob | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array>;
|
|
40
|
+
/** Per-request options for {@link ApiClient} calls. */
|
|
41
|
+
export interface RequestOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Content type of the body. Binary bodies default to the Blob's own type,
|
|
44
|
+
* else `application/octet-stream`; JSON bodies to `application/json`.
|
|
45
|
+
* Ignored for `FormData`, whose boundary the runtime sets.
|
|
46
|
+
*/
|
|
47
|
+
contentType?: string;
|
|
48
|
+
/** Additional request headers, such as `content-range` for a chunk. */
|
|
49
|
+
headers?: Record<string, string>;
|
|
50
|
+
/** Cancels the request, for example when the user abandons an upload. */
|
|
51
|
+
signal?: AbortSignal;
|
|
31
52
|
}
|
|
32
53
|
/**
|
|
33
54
|
* Create a typed API client. Every frontend talks to the PodoKit backend
|
|
@@ -35,8 +56,10 @@ export declare class ApiError extends Error {
|
|
|
35
56
|
*
|
|
36
57
|
* - `client.auth` is the better-auth client (email/password, sessions, and the
|
|
37
58
|
* admin plugin: `client.auth.admin.listUsers()`, `banUser`, `setRole`, ...).
|
|
38
|
-
* - `client.get/post/put/patch/del` call the app's
|
|
39
|
-
* the standard error envelope, throwing {@link ApiError} on failure.
|
|
59
|
+
* - `client.get/post/put/patch/del` call the app's REST endpoints and parse
|
|
60
|
+
* the standard error envelope, throwing {@link ApiError} on failure. Bodies
|
|
61
|
+
* are sent as JSON, except `FormData` (multipart, with the runtime's
|
|
62
|
+
* boundary) and {@link BinaryBody} values, which are sent as they are.
|
|
40
63
|
* - `client.postForm` sends multipart `FormData` without overriding the browser's
|
|
41
64
|
* boundary-aware content type.
|
|
42
65
|
*/
|
|
@@ -2675,7 +2698,17 @@ export declare function createApiClient(options?: ApiClientOptions): {
|
|
|
2675
2698
|
expiresAt: Date;
|
|
2676
2699
|
token: string;
|
|
2677
2700
|
ipAddress?: string | null | undefined;
|
|
2678
|
-
userAgent
|
|
2701
|
+
userAgent
|
|
2702
|
+
/**
|
|
2703
|
+
* Content type of the body. Binary bodies default to the Blob's own type,
|
|
2704
|
+
* else `application/octet-stream`; JSON bodies to `application/json`.
|
|
2705
|
+
* Ignored for `FormData`, whose boundary the runtime sets.
|
|
2706
|
+
*/
|
|
2707
|
+
? /**
|
|
2708
|
+
* Content type of the body. Binary bodies default to the Blob's own type,
|
|
2709
|
+
* else `application/octet-stream`; JSON bodies to `application/json`.
|
|
2710
|
+
* Ignored for `FormData`, whose boundary the runtime sets.
|
|
2711
|
+
*/: string | null | undefined;
|
|
2679
2712
|
};
|
|
2680
2713
|
user: Record<string, any> & {
|
|
2681
2714
|
id: string;
|
|
@@ -5318,13 +5351,13 @@ export declare function createApiClient(options?: ApiClientOptions): {
|
|
|
5318
5351
|
};
|
|
5319
5352
|
}>>;
|
|
5320
5353
|
/** Low-level typed request against the app's REST API. */
|
|
5321
|
-
request: <T>(method: string, path: string, body?: unknown) => Promise<T>;
|
|
5322
|
-
get: <T>(path: string) => Promise<T>;
|
|
5323
|
-
post: <T>(path: string, body?: unknown) => Promise<T>;
|
|
5324
|
-
put: <T>(path: string, body?: unknown) => Promise<T>;
|
|
5325
|
-
patch: <T>(path: string, body?: unknown) => Promise<T>;
|
|
5326
|
-
del: <T>(path: string) => Promise<T>;
|
|
5327
|
-
postForm: <T>(path: string, body: FormData) => Promise<T>;
|
|
5354
|
+
request: <T>(method: string, path: string, body?: unknown, options?: RequestOptions) => Promise<T>;
|
|
5355
|
+
get: <T>(path: string, options?: RequestOptions) => Promise<T>;
|
|
5356
|
+
post: <T>(path: string, body?: unknown, options?: RequestOptions) => Promise<T>;
|
|
5357
|
+
put: <T>(path: string, body?: unknown, options?: RequestOptions) => Promise<T>;
|
|
5358
|
+
patch: <T>(path: string, body?: unknown, options?: RequestOptions) => Promise<T>;
|
|
5359
|
+
del: <T>(path: string, options?: RequestOptions) => Promise<T>;
|
|
5360
|
+
postForm: <T>(path: string, body: FormData, options?: RequestOptions) => Promise<T>;
|
|
5328
5361
|
};
|
|
5329
5362
|
/** The client returned by {@link createApiClient}. */
|
|
5330
5363
|
export type ApiClient = ReturnType<typeof createApiClient>;
|
package/dist/index.js
CHANGED
|
@@ -9,23 +9,73 @@ export class ApiError extends Error {
|
|
|
9
9
|
code;
|
|
10
10
|
statusCode;
|
|
11
11
|
details;
|
|
12
|
-
|
|
12
|
+
/** Seconds to wait before retrying, from a `Retry-After` header (for example on 429 or 503). */
|
|
13
|
+
retryAfterSeconds;
|
|
14
|
+
constructor(code, message, statusCode, details, retryAfterSeconds) {
|
|
13
15
|
super(message);
|
|
14
16
|
this.name = "ApiError";
|
|
15
17
|
this.code = code;
|
|
16
18
|
this.statusCode = statusCode;
|
|
17
19
|
this.details = details;
|
|
20
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Reads `Retry-After` as delay seconds or an HTTP date. */
|
|
24
|
+
function retryAfterSeconds(response) {
|
|
25
|
+
const value = response.headers.get("retry-after")?.trim();
|
|
26
|
+
if (!value)
|
|
27
|
+
return undefined;
|
|
28
|
+
if (/^\d+$/.test(value))
|
|
29
|
+
return Number(value);
|
|
30
|
+
const date = Date.parse(value);
|
|
31
|
+
return Number.isNaN(date) ? undefined : Math.max(0, Math.ceil((date - Date.now()) / 1000));
|
|
32
|
+
}
|
|
33
|
+
/** An error body as JSON, or undefined when a proxy or server answered with text or HTML. */
|
|
34
|
+
function errorBody(text) {
|
|
35
|
+
try {
|
|
36
|
+
return text ? JSON.parse(text) : undefined;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
18
40
|
}
|
|
19
41
|
}
|
|
20
42
|
function toApiError(data, response) {
|
|
43
|
+
const retryAfter = retryAfterSeconds(response);
|
|
21
44
|
if (data && typeof data === "object" && "error" in data) {
|
|
22
45
|
const error = data.error;
|
|
23
46
|
if (error && typeof error === "object") {
|
|
24
47
|
const record = error;
|
|
25
|
-
return new ApiError(typeof record.code === "string" ? record.code : "HTTP_ERROR", typeof record.message === "string" ? record.message : response.statusText, typeof record.statusCode === "number" ? record.statusCode : response.status, record.details);
|
|
48
|
+
return new ApiError(typeof record.code === "string" ? record.code : "HTTP_ERROR", typeof record.message === "string" ? record.message : response.statusText, typeof record.statusCode === "number" ? record.statusCode : response.status, record.details, retryAfter);
|
|
26
49
|
}
|
|
27
50
|
}
|
|
28
|
-
return new ApiError("HTTP_ERROR", response.statusText || "Request failed", response.status);
|
|
51
|
+
return new ApiError("HTTP_ERROR", response.statusText || "Request failed", response.status, undefined, retryAfter);
|
|
52
|
+
}
|
|
53
|
+
function isBinaryBody(body) {
|
|
54
|
+
return ((typeof Blob !== "undefined" && body instanceof Blob) ||
|
|
55
|
+
body instanceof ArrayBuffer ||
|
|
56
|
+
ArrayBuffer.isView(body) ||
|
|
57
|
+
(typeof ReadableStream !== "undefined" && body instanceof ReadableStream));
|
|
58
|
+
}
|
|
59
|
+
function requestInit(method, credentials, body, options) {
|
|
60
|
+
const headers = { ...options.headers };
|
|
61
|
+
const init = { method, credentials, ...(options.signal ? { signal: options.signal } : {}) };
|
|
62
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
63
|
+
init.body = body;
|
|
64
|
+
}
|
|
65
|
+
else if (isBinaryBody(body)) {
|
|
66
|
+
headers["content-type"] =
|
|
67
|
+
options.contentType ?? (body instanceof Blob && body.type ? body.type : "application/octet-stream");
|
|
68
|
+
init.body = body;
|
|
69
|
+
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream)
|
|
70
|
+
init.duplex = "half";
|
|
71
|
+
}
|
|
72
|
+
else if (body !== undefined) {
|
|
73
|
+
headers["content-type"] = options.contentType ?? "application/json";
|
|
74
|
+
init.body = JSON.stringify(body);
|
|
75
|
+
}
|
|
76
|
+
if (Object.keys(headers).length > 0)
|
|
77
|
+
init.headers = headers;
|
|
78
|
+
return init;
|
|
29
79
|
}
|
|
30
80
|
/**
|
|
31
81
|
* Create a typed API client. Every frontend talks to the PodoKit backend
|
|
@@ -33,8 +83,10 @@ function toApiError(data, response) {
|
|
|
33
83
|
*
|
|
34
84
|
* - `client.auth` is the better-auth client (email/password, sessions, and the
|
|
35
85
|
* admin plugin: `client.auth.admin.listUsers()`, `banUser`, `setRole`, ...).
|
|
36
|
-
* - `client.get/post/put/patch/del` call the app's
|
|
37
|
-
* the standard error envelope, throwing {@link ApiError} on failure.
|
|
86
|
+
* - `client.get/post/put/patch/del` call the app's REST endpoints and parse
|
|
87
|
+
* the standard error envelope, throwing {@link ApiError} on failure. Bodies
|
|
88
|
+
* are sent as JSON, except `FormData` (multipart, with the runtime's
|
|
89
|
+
* boundary) and {@link BinaryBody} values, which are sent as they are.
|
|
38
90
|
* - `client.postForm` sends multipart `FormData` without overriding the browser's
|
|
39
91
|
* boundary-aware content type.
|
|
40
92
|
*/
|
|
@@ -79,32 +131,13 @@ export function createApiClient(options = {}) {
|
|
|
79
131
|
authClient ??= makeAuthClient();
|
|
80
132
|
return authClient;
|
|
81
133
|
}
|
|
82
|
-
async function request(method, path, body) {
|
|
83
|
-
const response = await doFetch(`${baseUrl}${apiBasePath}${path}`,
|
|
84
|
-
method,
|
|
85
|
-
credentials,
|
|
86
|
-
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
|
87
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
88
|
-
});
|
|
89
|
-
const text = await response.text();
|
|
90
|
-
const data = text ? JSON.parse(text) : undefined;
|
|
91
|
-
if (!response.ok) {
|
|
92
|
-
throw toApiError(data, response);
|
|
93
|
-
}
|
|
94
|
-
return data;
|
|
95
|
-
}
|
|
96
|
-
async function formRequest(method, path, body) {
|
|
97
|
-
const response = await doFetch(`${baseUrl}${apiBasePath}${path}`, {
|
|
98
|
-
method,
|
|
99
|
-
credentials,
|
|
100
|
-
body,
|
|
101
|
-
});
|
|
134
|
+
async function request(method, path, body, options = {}) {
|
|
135
|
+
const response = await doFetch(`${baseUrl}${apiBasePath}${path}`, requestInit(method, credentials, body, options));
|
|
102
136
|
const text = await response.text();
|
|
103
|
-
const data = text ? JSON.parse(text) : undefined;
|
|
104
137
|
if (!response.ok) {
|
|
105
|
-
throw toApiError(
|
|
138
|
+
throw toApiError(errorBody(text), response);
|
|
106
139
|
}
|
|
107
|
-
return
|
|
140
|
+
return (text ? JSON.parse(text) : undefined);
|
|
108
141
|
}
|
|
109
142
|
return {
|
|
110
143
|
/** The better-auth client (auth + admin plugin), created on first access. */
|
|
@@ -113,11 +146,11 @@ export function createApiClient(options = {}) {
|
|
|
113
146
|
},
|
|
114
147
|
/** Low-level typed request against the app's REST API. */
|
|
115
148
|
request,
|
|
116
|
-
get: (path) => request("GET", path),
|
|
117
|
-
post: (path, body) => request("POST", path, body),
|
|
118
|
-
put: (path, body) => request("PUT", path, body),
|
|
119
|
-
patch: (path, body) => request("PATCH", path, body),
|
|
120
|
-
del: (path) => request("DELETE", path),
|
|
121
|
-
postForm: (path, body) =>
|
|
149
|
+
get: (path, options) => request("GET", path, undefined, options),
|
|
150
|
+
post: (path, body, options) => request("POST", path, body, options),
|
|
151
|
+
put: (path, body, options) => request("PUT", path, body, options),
|
|
152
|
+
patch: (path, body, options) => request("PATCH", path, body, options),
|
|
153
|
+
del: (path, options) => request("DELETE", path, undefined, options),
|
|
154
|
+
postForm: (path, body, options) => request("POST", path, body, options),
|
|
122
155
|
};
|
|
123
156
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podosoft/podokit-api-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Typed API client for PodoKit backends: better-auth (auth + admin) plus a standard error-envelope request layer.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"provenance": true
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@podosoft/podokit-contracts": "^0.
|
|
32
|
+
"@podosoft/podokit-contracts": "^0.5.0",
|
|
33
33
|
"better-auth": "^1.7.7",
|
|
34
34
|
"@better-auth/api-key": "^1.7.7",
|
|
35
35
|
"@better-auth/passkey": "^1.7.7"
|