@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 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
@@ -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
- /** `FormData` goes out as multipart untouched; anything else is JSON-encoded. */
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 };
@@ -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, { ...init, credentials: "same-origin" }).then(
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
- resolve({
101
- status: xhr.status,
102
- statusText: xhr.statusText,
103
- text: xhr.responseText
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
- async function request(config, method, path, body, { signal, query, onUploadProgress }) {
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
- { method, headers, body: payload, signal },
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 new ApiError(0, UNREACHABLE);
162
+ throw unreachable(config);
144
163
  }
145
- if (response.status === 204) {
146
- return void 0;
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 = response.text === "" ? void 0 : JSON.parse(response.text);
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.statusText,
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(config, "GET", path, void 0, options),
172
- post: (path, body, options = {}) => request(config, "POST", path, body, options),
173
- put: (path, body, options = {}) => request(config, "PUT", path, body, options),
174
- patch: (path, body, options = {}) => request(config, "PATCH", path, body, options),
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(config, "DELETE", path, void 0, options)
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godxjp/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "GoDX JavaScript SDK \u2014 shared frontend infrastructure for GoDX products.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",