@godxjp/sdk 0.2.0 → 0.4.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,78 @@ 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
+ // `ctx` is the request that was answered 401 — method, URL and the headers
103
+ // it was sent with — so a login that failed is not mistaken for an expired
104
+ // token.
105
+ onUnauthorized: ({ headers }) =>
106
+ headers.Authorization === undefined
107
+ ? Promise.resolve(false)
108
+ : tokens.refresh(),
109
+ // A request that never reached the server: `ApiError` with status 0 by
110
+ // default ("wrap"); "throw" rethrows fetch's own `TypeError`.
111
+ networkErrors: "throw",
112
+ });
113
+ ```
114
+
115
+ 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`, whatever `networkErrors` says.
116
+
117
+ ### Downloading a file
118
+
119
+ ```ts
120
+ const { blob, headers, status } = await apiClient.download("/exports", {
121
+ method: "POST", // "GET" unless given
122
+ body: { ids: [1, 2] },
123
+ query: { format: "csv" },
124
+ });
125
+ const disposition = headers.get("Content-Disposition");
126
+ ```
127
+
128
+ Same headers, XSRF header, retry and errors as every other method: a 4xx, a 419 or an unreachable server is still an `ApiError`.
129
+
130
+ ### Error messages
131
+
132
+ `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.
133
+
134
+ ```ts
135
+ createClient({
136
+ baseUrl: "/api",
137
+ messages: (status) => (status === 419 ? t("session.expired") : undefined),
138
+ });
139
+ ```
140
+
141
+ Everything else the server sent is on `ApiError.body` — the parsed JSON, or `undefined` when there was none, it was not JSON, or the server was never reached. Read an `error` code, a `message` that is not a string, or every message per field from there:
142
+
143
+ ```ts
144
+ if (error instanceof ApiError) {
145
+ const { error: code, errors } = (error.body ?? {}) as {
146
+ error?: string;
147
+ errors?: Record<string, string[]>;
148
+ };
149
+ }
150
+ ```
151
+
152
+ 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.
153
+
154
+ **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`.
155
+
156
+ **Changed in 0.4.0.** Additions only; a client configured for 0.3.0 sends and throws exactly what it did. `ApiError` gains `body` (also on a `download()` failure). `onUnauthorized` now receives the request that was answered 401 (`{ method, url, headers }`); a function that takes no argument still works. `networkErrors: "throw"` rethrows `fetch`'s own error instead of `ApiError(0)` — including a response body that breaks off mid-way.
157
+
86
158
  ## `./realtime`
87
159
 
88
160
  ```ts
@@ -13,7 +13,14 @@ declare class ApiError extends Error {
13
13
  readonly status: number;
14
14
  /** Laravel's `{errors: {field: string[]}}`, first message per key; `{}` when absent. */
15
15
  readonly errors: Record<string, string>;
16
- constructor(status: number, message: string, errors?: Record<string, string>);
16
+ /**
17
+ * The error response's body, parsed as JSON — everything `message` and
18
+ * `errors` leave out: an `error` code, a `message` that is not a string,
19
+ * every message per field. `undefined` when there was no body, it was not
20
+ * JSON, or the server was never reached.
21
+ */
22
+ readonly body: unknown;
23
+ constructor(status: number, message: string, errors?: Record<string, string>, body?: unknown);
17
24
  }
18
25
 
19
26
  /**
@@ -45,6 +52,26 @@ type WriteOptions = RequestOptions & {
45
52
  */
46
53
  onUploadProgress?: (percent: number) => void;
47
54
  };
55
+ type DownloadOptions = RequestOptions & {
56
+ /** `GET` unless given. A `POST` carries the XSRF header like any write. */
57
+ method?: "GET" | "POST";
58
+ body?: Body;
59
+ query?: ApiQuery;
60
+ };
61
+ /** A binary response, with the headers a screen reads the file name from. */
62
+ type DownloadResult = {
63
+ blob: Blob;
64
+ headers: Headers;
65
+ status: number;
66
+ };
67
+ /** The request a 401 answered, as `onUnauthorized` receives it. */
68
+ type UnauthorizedContext = {
69
+ method: string;
70
+ /** The full URL, `baseUrl` and query string included. */
71
+ url: string;
72
+ /** Exactly the headers the request was sent with. */
73
+ headers: Record<string, string>;
74
+ };
48
75
  type ClientConfig = {
49
76
  /** Prefixed to every path, e.g. `/api/app/v1`. */
50
77
  baseUrl: string;
@@ -53,6 +80,44 @@ type ClientConfig = {
53
80
  * Omitted for a public API whose visitor has nowhere to sign in.
54
81
  */
55
82
  signInUrl?: (returnTo: string) => string;
83
+ /**
84
+ * Extra headers, read again before EVERY request (and before a retry), so a
85
+ * bearer token refreshed a moment ago is the one that goes out. Applied
86
+ * after the defaults, so a header named here wins.
87
+ */
88
+ headers?: () => Record<string, string> | Promise<Record<string, string>>;
89
+ /**
90
+ * `fetch` credentials mode; `same-origin` unless given. `include` is what a
91
+ * cross-origin API behind a cookie needs, and also turns on
92
+ * `withCredentials` for an upload that goes out over XHR.
93
+ */
94
+ credentials?: RequestCredentials;
95
+ /**
96
+ * Called on a 401 before `signInUrl`. Resolve `true` (a token was refreshed,
97
+ * say) and the request is sent once more, headers read afresh; resolve
98
+ * `false` and the 401 takes its usual course. A request is retried at most
99
+ * once, and 401s that arrive together share one call — made with the
100
+ * context of the request that started it.
101
+ *
102
+ * `ctx` describes the request that was answered 401, with the headers it
103
+ * was actually sent with — so a host can refresh only when that request
104
+ * carried a token.
105
+ */
106
+ onUnauthorized?: (ctx: UnauthorizedContext) => Promise<boolean>;
107
+ /**
108
+ * What a request that never reached the server throws. `wrap` (the
109
+ * default) turns it into `ApiError` with status `0`; `throw` rethrows what
110
+ * `fetch` threw (a `TypeError`), for a host that tells a network failure
111
+ * from an HTTP one by exactly that. A cancelled request is an `AbortError`
112
+ * either way.
113
+ */
114
+ networkErrors?: "wrap" | "throw";
115
+ /**
116
+ * The message for an `ApiError` whose response carried none — the client
117
+ * has no language of its own. Unset, or answering `undefined`, leaves
118
+ * `message` empty; status `0` then keeps its built-in text.
119
+ */
120
+ messages?: (status: number) => string | undefined;
56
121
  };
57
122
  type ApiClient = {
58
123
  get: <T>(path: string, options?: GetOptions) => Promise<T>;
@@ -60,8 +125,17 @@ type ApiClient = {
60
125
  put: <T>(path: string, body?: Body, options?: WriteOptions) => Promise<T>;
61
126
  patch: <T>(path: string, body?: Body, options?: WriteOptions) => Promise<T>;
62
127
  delete: <T>(path: string, options?: GetOptions) => Promise<T>;
128
+ /**
129
+ * The response as a `Blob` plus its headers, through the same error
130
+ * handling as every other method: a failure is still an `ApiError`.
131
+ */
132
+ download: (path: string, options?: DownloadOptions) => Promise<DownloadResult>;
63
133
  };
64
- /** `FormData` goes out as multipart untouched; anything else is JSON-encoded. */
134
+ /**
135
+ * `FormData` goes out as multipart untouched; anything else is JSON-encoded.
136
+ * Either way the payload can be sent twice, which is what makes the retry
137
+ * after `onUnauthorized` safe — a stream body is never produced.
138
+ */
65
139
  type Body = FormData | object;
66
140
  /** Laravel reads `key[]=a&key[]=b` as an array and '1'/'0' as booleans. */
67
141
  declare function queryString(query: ApiQuery | undefined): string;
@@ -69,4 +143,4 @@ declare function queryString(query: ApiQuery | undefined): string;
69
143
  declare function flattenErrors(errors: unknown): Record<string, string>;
70
144
  declare function createClient(config: ClientConfig): ApiClient;
71
145
 
72
- export { type ApiClient, ApiError, type ApiQuery, type ClientConfig, type GetOptions, type WriteOptions, createClient, flattenErrors, queryString };
146
+ export { type ApiClient, ApiError, type ApiQuery, type ClientConfig, type DownloadOptions, type DownloadResult, type GetOptions, type UnauthorizedContext, type WriteOptions, createClient, flattenErrors, queryString };
@@ -3,16 +3,23 @@ var ApiError = class extends Error {
3
3
  status;
4
4
  /** Laravel's `{errors: {field: string[]}}`, first message per key; `{}` when absent. */
5
5
  errors;
6
- constructor(status, message, errors = {}) {
6
+ /**
7
+ * The error response's body, parsed as JSON — everything `message` and
8
+ * `errors` leave out: an `error` code, a `message` that is not a string,
9
+ * every message per field. `undefined` when there was no body, it was not
10
+ * JSON, or the server was never reached.
11
+ */
12
+ body;
13
+ constructor(status, message, errors = {}, body) {
7
14
  super(message);
8
15
  this.name = "ApiError";
9
16
  this.status = status;
10
17
  this.errors = errors;
18
+ this.body = body;
11
19
  }
12
20
  };
13
21
 
14
22
  // src/laravel/api-client.ts
15
- var SESSION_EXPIRED = "Your session has expired. Reload the page and try again.";
16
23
  var UNREACHABLE = "Could not reach the server. Check your connection and try again.";
17
24
  var redirectingToSignIn = false;
18
25
  function readCookie(name) {
@@ -75,18 +82,13 @@ function redirectToSignIn(signInUrl) {
75
82
  }
76
83
  function send(url, init, onUploadProgress) {
77
84
  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
- );
85
+ return fetch(url, init);
85
86
  }
86
87
  return new Promise((resolve, reject) => {
87
88
  const xhr = new XMLHttpRequest();
88
89
  const abort = () => xhr.abort();
89
90
  xhr.open(init.method, url);
91
+ xhr.withCredentials = init.credentials === "include";
90
92
  for (const [name, value] of Object.entries(init.headers)) {
91
93
  xhr.setRequestHeader(name, value);
92
94
  }
@@ -97,11 +99,16 @@ function send(url, init, onUploadProgress) {
97
99
  };
98
100
  xhr.onload = () => {
99
101
  init.signal?.removeEventListener("abort", abort);
100
- resolve({
101
- status: xhr.status,
102
- statusText: xhr.statusText,
103
- text: xhr.responseText
104
- });
102
+ const empty = xhr.responseText === "" || [204, 205, 304].includes(xhr.status);
103
+ try {
104
+ resolve(
105
+ new Response(empty ? null : xhr.responseText, {
106
+ status: xhr.status
107
+ })
108
+ );
109
+ } catch (error) {
110
+ reject(error);
111
+ }
105
112
  };
106
113
  xhr.onerror = () => reject(new TypeError("Network request failed"));
107
114
  xhr.onabort = () => reject(new DOMException("The request was aborted.", "AbortError"));
@@ -113,7 +120,23 @@ function send(url, init, onUploadProgress) {
113
120
  xhr.send(init.body ?? null);
114
121
  });
115
122
  }
116
- async function request(config, method, path, body, { signal, query, onUploadProgress }) {
123
+ function unreachable(config, error) {
124
+ if (config.networkErrors === "throw") {
125
+ return error;
126
+ }
127
+ return new ApiError(0, config.messages?.(0) ?? UNREACHABLE);
128
+ }
129
+ async function read(response, as, config, signal) {
130
+ try {
131
+ return await response[as]();
132
+ } catch (error) {
133
+ if (signal?.aborted) {
134
+ throw error;
135
+ }
136
+ throw unreachable(config, error);
137
+ }
138
+ }
139
+ async function exchange({ config, unauthorized }, method, path, body, { signal, query, onUploadProgress }, retried = false) {
117
140
  const headers = {
118
141
  Accept: "application/json",
119
142
  "X-Requested-With": "XMLHttpRequest"
@@ -129,51 +152,95 @@ async function request(config, method, path, body, { signal, query, onUploadProg
129
152
  headers["Content-Type"] = "application/json";
130
153
  payload = JSON.stringify(body);
131
154
  }
155
+ Object.assign(headers, await config.headers?.());
156
+ const url = `${config.baseUrl}${path}${queryString(query)}`;
132
157
  let response;
133
158
  try {
134
159
  response = await send(
135
- `${config.baseUrl}${path}${queryString(query)}`,
136
- { method, headers, body: payload, signal },
160
+ url,
161
+ {
162
+ method,
163
+ headers,
164
+ body: payload,
165
+ signal,
166
+ credentials: config.credentials ?? "same-origin"
167
+ },
137
168
  onUploadProgress
138
169
  );
139
170
  } catch (error) {
140
171
  if (signal?.aborted) {
141
172
  throw error;
142
173
  }
143
- throw new ApiError(0, UNREACHABLE);
174
+ throw unreachable(config, error);
144
175
  }
145
- if (response.status === 204) {
146
- return void 0;
176
+ if (response.status >= 200 && response.status < 300) {
177
+ return response;
147
178
  }
179
+ if (response.status === 401 && !retried && config.onUnauthorized !== void 0 && await unauthorized({ method, url, headers: { ...headers } })) {
180
+ return exchange(
181
+ { config, unauthorized },
182
+ method,
183
+ path,
184
+ body,
185
+ { signal, query, onUploadProgress },
186
+ true
187
+ );
188
+ }
189
+ const text = await read(response, "text", config, signal);
148
190
  let json = void 0;
149
191
  try {
150
- json = response.text === "" ? void 0 : JSON.parse(response.text);
192
+ json = text === "" ? void 0 : JSON.parse(text);
151
193
  } catch {
152
194
  }
153
- if (response.status >= 200 && response.status < 300) {
154
- return json;
155
- }
156
195
  const bag = json ?? {};
157
196
  if (response.status === 401 && config.signInUrl !== void 0) {
158
197
  redirectToSignIn(config.signInUrl);
159
198
  }
160
- if (response.status === 419) {
161
- throw new ApiError(419, SESSION_EXPIRED);
162
- }
163
199
  throw new ApiError(
164
200
  response.status,
165
- typeof bag.message === "string" && bag.message !== "" ? bag.message : response.statusText,
166
- flattenErrors(bag.errors)
201
+ typeof bag.message === "string" && bag.message !== "" ? bag.message : config.messages?.(response.status) ?? "",
202
+ flattenErrors(bag.errors),
203
+ json
167
204
  );
168
205
  }
206
+ async function request(client, method, path, body, options) {
207
+ const response = await exchange(client, method, path, body, options);
208
+ if (response.status === 204) {
209
+ return void 0;
210
+ }
211
+ const text = await read(response, "text", client.config, options.signal);
212
+ try {
213
+ return text === "" ? void 0 : JSON.parse(text);
214
+ } catch {
215
+ return void 0;
216
+ }
217
+ }
169
218
  function createClient(config) {
219
+ let refreshing;
220
+ const client = {
221
+ config,
222
+ unauthorized: (ctx) => refreshing ??= (config.onUnauthorized?.(ctx) ?? Promise.resolve(false)).finally(() => {
223
+ refreshing = void 0;
224
+ })
225
+ };
170
226
  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),
227
+ get: (path, options = {}) => request(client, "GET", path, void 0, options),
228
+ post: (path, body, options = {}) => request(client, "POST", path, body, options),
229
+ put: (path, body, options = {}) => request(client, "PUT", path, body, options),
230
+ patch: (path, body, options = {}) => request(client, "PATCH", path, body, options),
175
231
  /** `query` for what identifies the targets (`?ids[]=1&ids[]=2`). */
176
- delete: (path, options = {}) => request(config, "DELETE", path, void 0, options)
232
+ delete: (path, options = {}) => request(client, "DELETE", path, void 0, options),
233
+ download: async (path, { method = "GET", body, query, signal } = {}) => {
234
+ const response = await exchange(client, method, path, body, {
235
+ query,
236
+ signal
237
+ });
238
+ return {
239
+ blob: await read(response, "blob", config, signal),
240
+ headers: response.headers,
241
+ status: response.status
242
+ };
243
+ }
177
244
  };
178
245
  }
179
246
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godxjp/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "GoDX JavaScript SDK \u2014 shared frontend infrastructure for GoDX products.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",