@godxjp/sdk 0.2.0 → 0.3.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 +50 -0
- package/dist/laravel/index.d.ts +48 -2
- package/dist/laravel/index.js +85 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,6 +83,56 @@ Authentication is the session cookie, so a write sends the `XSRF-TOKEN` cookie b
|
|
|
83
83
|
|
|
84
84
|
A cancellation stays an `AbortError` and is never converted to an `ApiError` — TanStack Query tells a cancelled query from a failed one by exactly that.
|
|
85
85
|
|
|
86
|
+
### Other ways to authenticate
|
|
87
|
+
|
|
88
|
+
Every option below is optional; left out, the client behaves exactly as described above.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
export const apiClient = createClient({
|
|
92
|
+
baseUrl: "https://api.example.com/v1",
|
|
93
|
+
// Read before every request — and again before a retry — so a token
|
|
94
|
+
// refreshed a moment ago is the one that goes out. Wins over the defaults.
|
|
95
|
+
headers: async () => ({ Authorization: `Bearer ${await tokens.access()}` }),
|
|
96
|
+
// `fetch` credentials mode, `same-origin` by default. `include` also sets
|
|
97
|
+
// `withCredentials` on an upload that goes out over XHR.
|
|
98
|
+
credentials: "include",
|
|
99
|
+
// On a 401, before `signInUrl`: resolve `true` and the request is sent once
|
|
100
|
+
// more; resolve `false` and the 401 takes its usual course. Never more than
|
|
101
|
+
// one retry per request, and 401s that arrive together share one call.
|
|
102
|
+
onUnauthorized: () => tokens.refresh(),
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
A request body is always JSON text or `FormData`, never a stream, so it can be sent a second time. A request cancelled while the token was refreshed stays an `AbortError`.
|
|
107
|
+
|
|
108
|
+
### Downloading a file
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const { blob, headers, status } = await apiClient.download("/exports", {
|
|
112
|
+
method: "POST", // "GET" unless given
|
|
113
|
+
body: { ids: [1, 2] },
|
|
114
|
+
query: { format: "csv" },
|
|
115
|
+
});
|
|
116
|
+
const disposition = headers.get("Content-Disposition");
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Same headers, XSRF header, retry and errors as every other method: a 4xx, a 419 or an unreachable server is still an `ApiError`.
|
|
120
|
+
|
|
121
|
+
### Error messages
|
|
122
|
+
|
|
123
|
+
`ApiError.message` is the server's `message` when it sent a non-empty one. Otherwise it comes from `messages`, or is empty — the client has no language of its own, and never uses `statusText`, which HTTP/2 does not send.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
createClient({
|
|
127
|
+
baseUrl: "/api",
|
|
128
|
+
messages: (status) => (status === 419 ? t("session.expired") : undefined),
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
An unreachable server (`status: 0`) keeps its built-in text, `Could not reach the server. Check your connection and try again.`, unless `messages(0)` answers one.
|
|
133
|
+
|
|
134
|
+
**Changed in 0.3.0.** Before, a 419 always carried `Your session has expired. Reload the page and try again.` and a response without a message fell back to `statusText`. Both are now the server's message or empty. To keep the old 419 text, pass `messages: (status) => status === 419 ? "Your session has expired. Reload the page and try again." : undefined`.
|
|
135
|
+
|
|
86
136
|
## `./realtime`
|
|
87
137
|
|
|
88
138
|
```ts
|
package/dist/laravel/index.d.ts
CHANGED
|
@@ -45,6 +45,18 @@ type WriteOptions = RequestOptions & {
|
|
|
45
45
|
*/
|
|
46
46
|
onUploadProgress?: (percent: number) => void;
|
|
47
47
|
};
|
|
48
|
+
type DownloadOptions = RequestOptions & {
|
|
49
|
+
/** `GET` unless given. A `POST` carries the XSRF header like any write. */
|
|
50
|
+
method?: "GET" | "POST";
|
|
51
|
+
body?: Body;
|
|
52
|
+
query?: ApiQuery;
|
|
53
|
+
};
|
|
54
|
+
/** A binary response, with the headers a screen reads the file name from. */
|
|
55
|
+
type DownloadResult = {
|
|
56
|
+
blob: Blob;
|
|
57
|
+
headers: Headers;
|
|
58
|
+
status: number;
|
|
59
|
+
};
|
|
48
60
|
type ClientConfig = {
|
|
49
61
|
/** Prefixed to every path, e.g. `/api/app/v1`. */
|
|
50
62
|
baseUrl: string;
|
|
@@ -53,6 +65,31 @@ type ClientConfig = {
|
|
|
53
65
|
* Omitted for a public API whose visitor has nowhere to sign in.
|
|
54
66
|
*/
|
|
55
67
|
signInUrl?: (returnTo: string) => string;
|
|
68
|
+
/**
|
|
69
|
+
* Extra headers, read again before EVERY request (and before a retry), so a
|
|
70
|
+
* bearer token refreshed a moment ago is the one that goes out. Applied
|
|
71
|
+
* after the defaults, so a header named here wins.
|
|
72
|
+
*/
|
|
73
|
+
headers?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
74
|
+
/**
|
|
75
|
+
* `fetch` credentials mode; `same-origin` unless given. `include` is what a
|
|
76
|
+
* cross-origin API behind a cookie needs, and also turns on
|
|
77
|
+
* `withCredentials` for an upload that goes out over XHR.
|
|
78
|
+
*/
|
|
79
|
+
credentials?: RequestCredentials;
|
|
80
|
+
/**
|
|
81
|
+
* Called on a 401 before `signInUrl`. Resolve `true` (a token was refreshed,
|
|
82
|
+
* say) and the request is sent once more, headers read afresh; resolve
|
|
83
|
+
* `false` and the 401 takes its usual course. A request is retried at most
|
|
84
|
+
* once, and 401s that arrive together share one call.
|
|
85
|
+
*/
|
|
86
|
+
onUnauthorized?: () => Promise<boolean>;
|
|
87
|
+
/**
|
|
88
|
+
* The message for an `ApiError` whose response carried none — the client
|
|
89
|
+
* has no language of its own. Unset, or answering `undefined`, leaves
|
|
90
|
+
* `message` empty; status `0` then keeps its built-in text.
|
|
91
|
+
*/
|
|
92
|
+
messages?: (status: number) => string | undefined;
|
|
56
93
|
};
|
|
57
94
|
type ApiClient = {
|
|
58
95
|
get: <T>(path: string, options?: GetOptions) => Promise<T>;
|
|
@@ -60,8 +97,17 @@ type ApiClient = {
|
|
|
60
97
|
put: <T>(path: string, body?: Body, options?: WriteOptions) => Promise<T>;
|
|
61
98
|
patch: <T>(path: string, body?: Body, options?: WriteOptions) => Promise<T>;
|
|
62
99
|
delete: <T>(path: string, options?: GetOptions) => Promise<T>;
|
|
100
|
+
/**
|
|
101
|
+
* The response as a `Blob` plus its headers, through the same error
|
|
102
|
+
* handling as every other method: a failure is still an `ApiError`.
|
|
103
|
+
*/
|
|
104
|
+
download: (path: string, options?: DownloadOptions) => Promise<DownloadResult>;
|
|
63
105
|
};
|
|
64
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* `FormData` goes out as multipart untouched; anything else is JSON-encoded.
|
|
108
|
+
* Either way the payload can be sent twice, which is what makes the retry
|
|
109
|
+
* after `onUnauthorized` safe — a stream body is never produced.
|
|
110
|
+
*/
|
|
65
111
|
type Body = FormData | object;
|
|
66
112
|
/** Laravel reads `key[]=a&key[]=b` as an array and '1'/'0' as booleans. */
|
|
67
113
|
declare function queryString(query: ApiQuery | undefined): string;
|
|
@@ -69,4 +115,4 @@ declare function queryString(query: ApiQuery | undefined): string;
|
|
|
69
115
|
declare function flattenErrors(errors: unknown): Record<string, string>;
|
|
70
116
|
declare function createClient(config: ClientConfig): ApiClient;
|
|
71
117
|
|
|
72
|
-
export { type ApiClient, ApiError, type ApiQuery, type ClientConfig, type GetOptions, type WriteOptions, createClient, flattenErrors, queryString };
|
|
118
|
+
export { type ApiClient, ApiError, type ApiQuery, type ClientConfig, type DownloadOptions, type DownloadResult, type GetOptions, type WriteOptions, createClient, flattenErrors, queryString };
|
package/dist/laravel/index.js
CHANGED
|
@@ -12,7 +12,6 @@ var ApiError = class extends Error {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
// src/laravel/api-client.ts
|
|
15
|
-
var SESSION_EXPIRED = "Your session has expired. Reload the page and try again.";
|
|
16
15
|
var UNREACHABLE = "Could not reach the server. Check your connection and try again.";
|
|
17
16
|
var redirectingToSignIn = false;
|
|
18
17
|
function readCookie(name) {
|
|
@@ -75,18 +74,13 @@ function redirectToSignIn(signInUrl) {
|
|
|
75
74
|
}
|
|
76
75
|
function send(url, init, onUploadProgress) {
|
|
77
76
|
if (onUploadProgress === void 0) {
|
|
78
|
-
return fetch(url,
|
|
79
|
-
async (response) => ({
|
|
80
|
-
status: response.status,
|
|
81
|
-
statusText: response.statusText,
|
|
82
|
-
text: await response.text()
|
|
83
|
-
})
|
|
84
|
-
);
|
|
77
|
+
return fetch(url, init);
|
|
85
78
|
}
|
|
86
79
|
return new Promise((resolve, reject) => {
|
|
87
80
|
const xhr = new XMLHttpRequest();
|
|
88
81
|
const abort = () => xhr.abort();
|
|
89
82
|
xhr.open(init.method, url);
|
|
83
|
+
xhr.withCredentials = init.credentials === "include";
|
|
90
84
|
for (const [name, value] of Object.entries(init.headers)) {
|
|
91
85
|
xhr.setRequestHeader(name, value);
|
|
92
86
|
}
|
|
@@ -97,11 +91,16 @@ function send(url, init, onUploadProgress) {
|
|
|
97
91
|
};
|
|
98
92
|
xhr.onload = () => {
|
|
99
93
|
init.signal?.removeEventListener("abort", abort);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
94
|
+
const empty = xhr.responseText === "" || [204, 205, 304].includes(xhr.status);
|
|
95
|
+
try {
|
|
96
|
+
resolve(
|
|
97
|
+
new Response(empty ? null : xhr.responseText, {
|
|
98
|
+
status: xhr.status
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
reject(error);
|
|
103
|
+
}
|
|
105
104
|
};
|
|
106
105
|
xhr.onerror = () => reject(new TypeError("Network request failed"));
|
|
107
106
|
xhr.onabort = () => reject(new DOMException("The request was aborted.", "AbortError"));
|
|
@@ -113,7 +112,20 @@ function send(url, init, onUploadProgress) {
|
|
|
113
112
|
xhr.send(init.body ?? null);
|
|
114
113
|
});
|
|
115
114
|
}
|
|
116
|
-
|
|
115
|
+
function unreachable(config) {
|
|
116
|
+
return new ApiError(0, config.messages?.(0) ?? UNREACHABLE);
|
|
117
|
+
}
|
|
118
|
+
async function read(response, as, config, signal) {
|
|
119
|
+
try {
|
|
120
|
+
return await response[as]();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (signal?.aborted) {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
throw unreachable(config);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function exchange({ config, unauthorized }, method, path, body, { signal, query, onUploadProgress }, retried = false) {
|
|
117
129
|
const headers = {
|
|
118
130
|
Accept: "application/json",
|
|
119
131
|
"X-Requested-With": "XMLHttpRequest"
|
|
@@ -129,51 +141,93 @@ async function request(config, method, path, body, { signal, query, onUploadProg
|
|
|
129
141
|
headers["Content-Type"] = "application/json";
|
|
130
142
|
payload = JSON.stringify(body);
|
|
131
143
|
}
|
|
144
|
+
Object.assign(headers, await config.headers?.());
|
|
132
145
|
let response;
|
|
133
146
|
try {
|
|
134
147
|
response = await send(
|
|
135
148
|
`${config.baseUrl}${path}${queryString(query)}`,
|
|
136
|
-
{
|
|
149
|
+
{
|
|
150
|
+
method,
|
|
151
|
+
headers,
|
|
152
|
+
body: payload,
|
|
153
|
+
signal,
|
|
154
|
+
credentials: config.credentials ?? "same-origin"
|
|
155
|
+
},
|
|
137
156
|
onUploadProgress
|
|
138
157
|
);
|
|
139
158
|
} catch (error) {
|
|
140
159
|
if (signal?.aborted) {
|
|
141
160
|
throw error;
|
|
142
161
|
}
|
|
143
|
-
throw
|
|
162
|
+
throw unreachable(config);
|
|
144
163
|
}
|
|
145
|
-
if (response.status
|
|
146
|
-
return
|
|
164
|
+
if (response.status >= 200 && response.status < 300) {
|
|
165
|
+
return response;
|
|
166
|
+
}
|
|
167
|
+
if (response.status === 401 && !retried && config.onUnauthorized !== void 0 && await unauthorized()) {
|
|
168
|
+
return exchange(
|
|
169
|
+
{ config, unauthorized },
|
|
170
|
+
method,
|
|
171
|
+
path,
|
|
172
|
+
body,
|
|
173
|
+
{ signal, query, onUploadProgress },
|
|
174
|
+
true
|
|
175
|
+
);
|
|
147
176
|
}
|
|
177
|
+
const text = await read(response, "text", config, signal);
|
|
148
178
|
let json = void 0;
|
|
149
179
|
try {
|
|
150
|
-
json =
|
|
180
|
+
json = text === "" ? void 0 : JSON.parse(text);
|
|
151
181
|
} catch {
|
|
152
182
|
}
|
|
153
|
-
if (response.status >= 200 && response.status < 300) {
|
|
154
|
-
return json;
|
|
155
|
-
}
|
|
156
183
|
const bag = json ?? {};
|
|
157
184
|
if (response.status === 401 && config.signInUrl !== void 0) {
|
|
158
185
|
redirectToSignIn(config.signInUrl);
|
|
159
186
|
}
|
|
160
|
-
if (response.status === 419) {
|
|
161
|
-
throw new ApiError(419, SESSION_EXPIRED);
|
|
162
|
-
}
|
|
163
187
|
throw new ApiError(
|
|
164
188
|
response.status,
|
|
165
|
-
typeof bag.message === "string" && bag.message !== "" ? bag.message : response.
|
|
189
|
+
typeof bag.message === "string" && bag.message !== "" ? bag.message : config.messages?.(response.status) ?? "",
|
|
166
190
|
flattenErrors(bag.errors)
|
|
167
191
|
);
|
|
168
192
|
}
|
|
193
|
+
async function request(client, method, path, body, options) {
|
|
194
|
+
const response = await exchange(client, method, path, body, options);
|
|
195
|
+
if (response.status === 204) {
|
|
196
|
+
return void 0;
|
|
197
|
+
}
|
|
198
|
+
const text = await read(response, "text", client.config, options.signal);
|
|
199
|
+
try {
|
|
200
|
+
return text === "" ? void 0 : JSON.parse(text);
|
|
201
|
+
} catch {
|
|
202
|
+
return void 0;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
169
205
|
function createClient(config) {
|
|
206
|
+
let refreshing;
|
|
207
|
+
const client = {
|
|
208
|
+
config,
|
|
209
|
+
unauthorized: () => refreshing ??= (config.onUnauthorized?.() ?? Promise.resolve(false)).finally(() => {
|
|
210
|
+
refreshing = void 0;
|
|
211
|
+
})
|
|
212
|
+
};
|
|
170
213
|
return {
|
|
171
|
-
get: (path, options = {}) => request(
|
|
172
|
-
post: (path, body, options = {}) => request(
|
|
173
|
-
put: (path, body, options = {}) => request(
|
|
174
|
-
patch: (path, body, options = {}) => request(
|
|
214
|
+
get: (path, options = {}) => request(client, "GET", path, void 0, options),
|
|
215
|
+
post: (path, body, options = {}) => request(client, "POST", path, body, options),
|
|
216
|
+
put: (path, body, options = {}) => request(client, "PUT", path, body, options),
|
|
217
|
+
patch: (path, body, options = {}) => request(client, "PATCH", path, body, options),
|
|
175
218
|
/** `query` for what identifies the targets (`?ids[]=1&ids[]=2`). */
|
|
176
|
-
delete: (path, options = {}) => request(
|
|
219
|
+
delete: (path, options = {}) => request(client, "DELETE", path, void 0, options),
|
|
220
|
+
download: async (path, { method = "GET", body, query, signal } = {}) => {
|
|
221
|
+
const response = await exchange(client, method, path, body, {
|
|
222
|
+
query,
|
|
223
|
+
signal
|
|
224
|
+
});
|
|
225
|
+
return {
|
|
226
|
+
blob: await read(response, "blob", config, signal),
|
|
227
|
+
headers: response.headers,
|
|
228
|
+
status: response.status
|
|
229
|
+
};
|
|
230
|
+
}
|
|
177
231
|
};
|
|
178
232
|
}
|
|
179
233
|
|