@godxjp/sdk 0.3.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
@@ -99,11 +99,20 @@ export const apiClient = createClient({
99
99
  // On a 401, before `signInUrl`: resolve `true` and the request is sent once
100
100
  // more; resolve `false` and the 401 takes its usual course. Never more than
101
101
  // one retry per request, and 401s that arrive together share one call.
102
- onUnauthorized: () => tokens.refresh(),
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",
103
112
  });
104
113
  ```
105
114
 
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`.
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.
107
116
 
108
117
  ### Downloading a file
109
118
 
@@ -129,10 +138,23 @@ createClient({
129
138
  });
130
139
  ```
131
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
+
132
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.
133
153
 
134
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`.
135
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
+
136
158
  ## `./realtime`
137
159
 
138
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
  /**
@@ -57,6 +64,14 @@ type DownloadResult = {
57
64
  headers: Headers;
58
65
  status: number;
59
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
+ };
60
75
  type ClientConfig = {
61
76
  /** Prefixed to every path, e.g. `/api/app/v1`. */
62
77
  baseUrl: string;
@@ -81,9 +96,22 @@ type ClientConfig = {
81
96
  * Called on a 401 before `signInUrl`. Resolve `true` (a token was refreshed,
82
97
  * say) and the request is sent once more, headers read afresh; resolve
83
98
  * `false` and the 401 takes its usual course. A request is retried at most
84
- * once, and 401s that arrive together share one call.
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.
85
113
  */
86
- onUnauthorized?: () => Promise<boolean>;
114
+ networkErrors?: "wrap" | "throw";
87
115
  /**
88
116
  * The message for an `ApiError` whose response carried none — the client
89
117
  * has no language of its own. Unset, or answering `undefined`, leaves
@@ -115,4 +143,4 @@ declare function queryString(query: ApiQuery | undefined): string;
115
143
  declare function flattenErrors(errors: unknown): Record<string, string>;
116
144
  declare function createClient(config: ClientConfig): ApiClient;
117
145
 
118
- export { type ApiClient, ApiError, type ApiQuery, type ClientConfig, type DownloadOptions, type DownloadResult, 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,11 +3,19 @@ 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
 
@@ -112,7 +120,10 @@ function send(url, init, onUploadProgress) {
112
120
  xhr.send(init.body ?? null);
113
121
  });
114
122
  }
115
- function unreachable(config) {
123
+ function unreachable(config, error) {
124
+ if (config.networkErrors === "throw") {
125
+ return error;
126
+ }
116
127
  return new ApiError(0, config.messages?.(0) ?? UNREACHABLE);
117
128
  }
118
129
  async function read(response, as, config, signal) {
@@ -122,7 +133,7 @@ async function read(response, as, config, signal) {
122
133
  if (signal?.aborted) {
123
134
  throw error;
124
135
  }
125
- throw unreachable(config);
136
+ throw unreachable(config, error);
126
137
  }
127
138
  }
128
139
  async function exchange({ config, unauthorized }, method, path, body, { signal, query, onUploadProgress }, retried = false) {
@@ -142,10 +153,11 @@ async function exchange({ config, unauthorized }, method, path, body, { signal,
142
153
  payload = JSON.stringify(body);
143
154
  }
144
155
  Object.assign(headers, await config.headers?.());
156
+ const url = `${config.baseUrl}${path}${queryString(query)}`;
145
157
  let response;
146
158
  try {
147
159
  response = await send(
148
- `${config.baseUrl}${path}${queryString(query)}`,
160
+ url,
149
161
  {
150
162
  method,
151
163
  headers,
@@ -159,12 +171,12 @@ async function exchange({ config, unauthorized }, method, path, body, { signal,
159
171
  if (signal?.aborted) {
160
172
  throw error;
161
173
  }
162
- throw unreachable(config);
174
+ throw unreachable(config, error);
163
175
  }
164
176
  if (response.status >= 200 && response.status < 300) {
165
177
  return response;
166
178
  }
167
- if (response.status === 401 && !retried && config.onUnauthorized !== void 0 && await unauthorized()) {
179
+ if (response.status === 401 && !retried && config.onUnauthorized !== void 0 && await unauthorized({ method, url, headers: { ...headers } })) {
168
180
  return exchange(
169
181
  { config, unauthorized },
170
182
  method,
@@ -187,7 +199,8 @@ async function exchange({ config, unauthorized }, method, path, body, { signal,
187
199
  throw new ApiError(
188
200
  response.status,
189
201
  typeof bag.message === "string" && bag.message !== "" ? bag.message : config.messages?.(response.status) ?? "",
190
- flattenErrors(bag.errors)
202
+ flattenErrors(bag.errors),
203
+ json
191
204
  );
192
205
  }
193
206
  async function request(client, method, path, body, options) {
@@ -206,7 +219,7 @@ function createClient(config) {
206
219
  let refreshing;
207
220
  const client = {
208
221
  config,
209
- unauthorized: () => refreshing ??= (config.onUnauthorized?.() ?? Promise.resolve(false)).finally(() => {
222
+ unauthorized: (ctx) => refreshing ??= (config.onUnauthorized?.(ctx) ?? Promise.resolve(false)).finally(() => {
210
223
  refreshing = void 0;
211
224
  })
212
225
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godxjp/sdk",
3
- "version": "0.3.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",