@optionfactory/fml 8.0.2 → 9.0.0-rc1
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/LICENSE.md +7 -0
- package/README.md +88 -0
- package/dist/client-errors.iife.js +30 -9
- package/dist/client-errors.iife.js.map +1 -1
- package/dist/client-errors.iife.min.js +1 -1
- package/dist/client-errors.iife.min.js.map +1 -1
- package/dist/custom-elements.json +1502 -408
- package/dist/fml.css +21 -10
- package/dist/fml.css.map +1 -1
- package/dist/fml.d.mts +3 -1299
- package/dist/fml.iife.js +5304 -2270
- package/dist/fml.iife.js.map +1 -1
- package/dist/fml.iife.min.js +1 -1
- package/dist/fml.iife.min.js.map +1 -1
- package/dist/fml.min.mjs +1 -1
- package/dist/fml.min.mjs.map +1 -1
- package/dist/fml.mjs +6 -8694
- package/dist/fml.mjs.map +1 -1
- package/dist/ftl.d.mts +433 -72
- package/dist/ftl.iife.js +1313 -807
- package/dist/ftl.iife.js.map +1 -1
- package/dist/ftl.iife.min.js +1 -1
- package/dist/ftl.iife.min.js.map +1 -1
- package/dist/ftl.min.mjs +1 -1
- package/dist/ftl.min.mjs.map +1 -1
- package/dist/ftl.mjs +1312 -808
- package/dist/ftl.mjs.map +1 -1
- package/dist/ful.css +21 -10
- package/dist/ful.css.map +1 -1
- package/dist/ful.d.mts +806 -257
- package/dist/ful.iife.js +3697 -1356
- package/dist/ful.iife.js.map +1 -1
- package/dist/ful.iife.min.js +1 -1
- package/dist/ful.iife.min.js.map +1 -1
- package/dist/ful.min.mjs +1 -1
- package/dist/ful.min.mjs.map +1 -1
- package/dist/ful.mjs +3686 -1356
- package/dist/ful.mjs.map +1 -1
- package/dist/httpc.d.mts +114 -19
- package/dist/httpc.iife.js +253 -83
- package/dist/httpc.iife.js.map +1 -1
- package/dist/httpc.iife.min.js +1 -1
- package/dist/httpc.iife.min.js.map +1 -1
- package/dist/httpc.min.mjs +1 -1
- package/dist/httpc.min.mjs.map +1 -1
- package/dist/httpc.mjs +250 -84
- package/dist/httpc.mjs.map +1 -1
- package/dist/vscode.html-custom-data.json +607 -65
- package/dist/web-types.json +1471 -376
- package/package.json +16 -8
package/dist/httpc.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @typedef {{ type: string; context: string?; reason: string; details: any?; }} Problem
|
|
3
3
|
*/
|
|
4
|
+
/**
|
|
5
|
+
* An error carrying a list of problems rather than one message. A problem's
|
|
6
|
+
* `context` names the field it belongs to, which is what lets a form show each
|
|
7
|
+
* one beside its own input instead of in a banner.
|
|
8
|
+
*/
|
|
4
9
|
class Failure extends Error {
|
|
5
10
|
/**
|
|
6
11
|
*
|
|
@@ -13,18 +18,55 @@ class Failure extends Error {
|
|
|
13
18
|
this.name = 'Failure';
|
|
14
19
|
this.problems = problems;
|
|
15
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Returns a copy whose problems' contexts have the prefix removed, so a
|
|
23
|
+
* caller can rethrow namespaced problems as its own.
|
|
24
|
+
* @param {string} prefix
|
|
25
|
+
* @returns {Failure}
|
|
26
|
+
*/
|
|
16
27
|
dropping(prefix) {
|
|
17
28
|
return new Failure(this.message, Failure.dropProblemsContext(this.problems, prefix), this);
|
|
18
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* @param {Problem[]} problems
|
|
32
|
+
* @param {string} prefix
|
|
33
|
+
* @returns {Problem[]}
|
|
34
|
+
*/
|
|
19
35
|
static dropProblemsContext(problems, prefix) {
|
|
20
36
|
return problems.map(({ type, context, reason, details }) => {
|
|
21
37
|
const nctx = context?.startsWith(prefix) ? context.substring(prefix.length) : context;
|
|
22
38
|
return { type, context: nctx, reason, details };
|
|
23
39
|
});
|
|
24
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* The one reading of a failure: its problems' reasons, one per line, or
|
|
43
|
+
* the fallback when the value carries none. An empty problems array
|
|
44
|
+
* carries nothing: the failure's own message reads instead.
|
|
45
|
+
*
|
|
46
|
+
* @param {any} cause
|
|
47
|
+
* @param {string|null} [fallback]
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
static problemsText(cause, fallback = null) {
|
|
51
|
+
if (cause?.problems?.length) {
|
|
52
|
+
return cause.problems.map((p) => `${p.reason}`).join('\n');
|
|
53
|
+
}
|
|
54
|
+
return fallback ?? `${cause?.message ?? cause}`;
|
|
55
|
+
}
|
|
25
56
|
}
|
|
26
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Base64 encoding and decoding over ArrayBuffers, in the STANDARD and URL_SAFE
|
|
60
|
+
* alphabets. The encoder never emits padding; the decoder accepts both padded
|
|
61
|
+
* and unpadded input, and rejects anything it cannot decode faithfully instead
|
|
62
|
+
* of corrupting silently.
|
|
63
|
+
*/
|
|
27
64
|
class Base64 {
|
|
65
|
+
/**
|
|
66
|
+
* @param {ArrayBuffer} arrayBuffer
|
|
67
|
+
* @param {string} [dialect] one of Base64.STANDARD or Base64.URL_SAFE, URL_SAFE by default
|
|
68
|
+
* @returns {string} the unpadded encoding
|
|
69
|
+
*/
|
|
28
70
|
static encode(arrayBuffer, dialect) {
|
|
29
71
|
const d = dialect || Base64.URL_SAFE;
|
|
30
72
|
const len = arrayBuffer.byteLength;
|
|
@@ -44,15 +86,32 @@ class Base64 {
|
|
|
44
86
|
}
|
|
45
87
|
return res;
|
|
46
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} str
|
|
91
|
+
* @param {string} [dialect] one of Base64.STANDARD or Base64.URL_SAFE, URL_SAFE by default
|
|
92
|
+
* @returns {ArrayBuffer}
|
|
93
|
+
*/
|
|
47
94
|
static decode(str, dialect) {
|
|
48
95
|
const d = dialect || Base64.URL_SAFE;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
96
|
+
//padding belongs at the tail only, two at most, and nothing outside the
|
|
97
|
+
//dialect decodes: reject instead of corrupting silently
|
|
98
|
+
let end = str.length;
|
|
99
|
+
while (end > 0 && str.charAt(end - 1) === '=') {
|
|
100
|
+
--end;
|
|
101
|
+
}
|
|
102
|
+
const unpadded = str.substring(0, end);
|
|
103
|
+
if (str.length - end > 2 || unpadded.includes('=') || (unpadded.length === 0 && str.length > 0)) {
|
|
104
|
+
throw new Error('invalid padding');
|
|
105
|
+
}
|
|
106
|
+
if (unpadded.length % 4 === 1) {
|
|
107
|
+
throw new Error('invalid length');
|
|
108
|
+
}
|
|
109
|
+
for (const c of unpadded) {
|
|
110
|
+
if (d.indexOf(c) === -1) {
|
|
111
|
+
throw new Error(`invalid character '${c}'`);
|
|
53
112
|
}
|
|
54
|
-
--nbytes;
|
|
55
113
|
}
|
|
114
|
+
const nbytes = Math.floor(unpadded.length * 0.75);
|
|
56
115
|
const view = new Uint8Array(nbytes);
|
|
57
116
|
|
|
58
117
|
let vi = 0;
|
|
@@ -61,10 +120,10 @@ class Base64 {
|
|
|
61
120
|
//one or two: writing them anyway would rely on typed arrays dropping writes
|
|
62
121
|
//past their length
|
|
63
122
|
while (vi < nbytes) {
|
|
64
|
-
const v1 = d.indexOf(
|
|
65
|
-
const v2 = d.indexOf(
|
|
66
|
-
const v3 = d.indexOf(
|
|
67
|
-
const v4 = d.indexOf(
|
|
123
|
+
const v1 = d.indexOf(unpadded.charAt(si++));
|
|
124
|
+
const v2 = d.indexOf(unpadded.charAt(si++));
|
|
125
|
+
const v3 = d.indexOf(unpadded.charAt(si++));
|
|
126
|
+
const v4 = d.indexOf(unpadded.charAt(si++));
|
|
68
127
|
view[vi++] = (v1 << 2) | (v2 >> 4);
|
|
69
128
|
if (vi === nbytes) {
|
|
70
129
|
break;
|
|
@@ -83,11 +142,21 @@ class Base64 {
|
|
|
83
142
|
Base64.STANDARD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
84
143
|
Base64.URL_SAFE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
85
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Hex encoding and decoding over byte sequences, lowercase by default.
|
|
147
|
+
*/
|
|
86
148
|
class Hex {
|
|
149
|
+
/**
|
|
150
|
+
* @param {string} hex
|
|
151
|
+
* @returns {Uint8Array}
|
|
152
|
+
*/
|
|
87
153
|
static decode(hex) {
|
|
88
154
|
if (hex.length % 2 !== 0) {
|
|
89
155
|
throw new Error('invalid length');
|
|
90
156
|
}
|
|
157
|
+
if (!/^[0-9a-fA-F]*$/.test(hex)) {
|
|
158
|
+
throw new Error('invalid character');
|
|
159
|
+
}
|
|
91
160
|
const lenInBytes = hex.length / 2;
|
|
92
161
|
return new Uint8Array(lenInBytes).map((e, i) => {
|
|
93
162
|
const offset = i * 2;
|
|
@@ -95,6 +164,11 @@ class Hex {
|
|
|
95
164
|
return parseInt(octet, 16);
|
|
96
165
|
});
|
|
97
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* @param {Iterable<number>} bytes
|
|
169
|
+
* @param {boolean} [upper]
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
98
172
|
static encode(bytes, upper) {
|
|
99
173
|
return Array.from(bytes)
|
|
100
174
|
.map((b) => b.toString(16))
|
|
@@ -104,6 +178,7 @@ class Hex {
|
|
|
104
178
|
}
|
|
105
179
|
}
|
|
106
180
|
|
|
181
|
+
/** A parsed `Content-Type`: the type and subtype without the parameters, so a comparison is not defeated by a charset. */
|
|
107
182
|
class MediaType {
|
|
108
183
|
#type;
|
|
109
184
|
#subtype;
|
|
@@ -121,7 +196,7 @@ class MediaType {
|
|
|
121
196
|
return this.#subtype;
|
|
122
197
|
}
|
|
123
198
|
/**
|
|
124
|
-
*
|
|
199
|
+
* Parses a Content-Type header value into its type/subtype pair, dropping any parameter.
|
|
125
200
|
* @param {string|null|undefined} v
|
|
126
201
|
* @returns
|
|
127
202
|
*/
|
|
@@ -130,8 +205,11 @@ class MediaType {
|
|
|
130
205
|
return new MediaType('unknown', 'unknown');
|
|
131
206
|
}
|
|
132
207
|
const [prefix, _] = v.split(';');
|
|
133
|
-
const [ptype, psubtype] = prefix.trim().split('/');
|
|
134
|
-
|
|
208
|
+
const [ptype, psubtype] = prefix.trim().toLowerCase().split('/');
|
|
209
|
+
if (!ptype || !psubtype) {
|
|
210
|
+
return new MediaType('unknown', 'unknown');
|
|
211
|
+
}
|
|
212
|
+
return new MediaType(ptype, psubtype);
|
|
135
213
|
}
|
|
136
214
|
}
|
|
137
215
|
|
|
@@ -143,6 +221,11 @@ class MediaType {
|
|
|
143
221
|
* @property {(url: URL, init: RequestInit|undefined, chain: HttpInterceptorChain) => Promise<Response>} intercept
|
|
144
222
|
*/
|
|
145
223
|
|
|
224
|
+
/**
|
|
225
|
+
* A Failure from an http exchange, carrying the status that was served. Status
|
|
226
|
+
* 0 means no response was served at all: the transport failed, or a body the
|
|
227
|
+
* server did send could not be read.
|
|
228
|
+
*/
|
|
146
229
|
class HttpClientError extends Failure {
|
|
147
230
|
/**
|
|
148
231
|
* @param {string} message
|
|
@@ -155,29 +238,33 @@ class HttpClientError extends Failure {
|
|
|
155
238
|
this.name = 'HttpClientError';
|
|
156
239
|
this.status = status;
|
|
157
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Returns a copy whose problems' contexts have the prefix removed, keeping
|
|
243
|
+
* this error's status.
|
|
244
|
+
* @param {string} prefix
|
|
245
|
+
* @returns {HttpClientError}
|
|
246
|
+
*/
|
|
158
247
|
dropping(prefix) {
|
|
159
248
|
return new HttpClientError(this.message, this.status, Failure.dropProblemsContext(this.problems, prefix), this);
|
|
160
249
|
}
|
|
161
250
|
/**
|
|
162
|
-
*
|
|
251
|
+
* One problem of the client's own making: the four the client mints are the
|
|
252
|
+
* same shape, and the server's arrive already shaped from the wire.
|
|
253
|
+
* @param {string} type
|
|
254
|
+
* @param {string} reason
|
|
255
|
+
*/
|
|
256
|
+
static problem(type, reason) {
|
|
257
|
+
return { type, context: null, reason, details: null };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Creates a client failure carrying no status, wrapping the cause and its message.
|
|
163
261
|
* @param {string} type
|
|
164
262
|
* @param {any} cause
|
|
165
263
|
* @returns
|
|
166
264
|
*/
|
|
167
265
|
static of(type, cause) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
0,
|
|
171
|
-
[
|
|
172
|
-
{
|
|
173
|
-
type,
|
|
174
|
-
context: null,
|
|
175
|
-
reason: cause.message,
|
|
176
|
-
details: null,
|
|
177
|
-
},
|
|
178
|
-
],
|
|
179
|
-
cause,
|
|
180
|
-
);
|
|
266
|
+
const reason = String(cause?.message ?? cause ?? 'unknown failure');
|
|
267
|
+
return new HttpClientError(reason, 0, [HttpClientError.problem(type, reason)], cause);
|
|
181
268
|
}
|
|
182
269
|
/**
|
|
183
270
|
* Creates an HttpClientError from a Response.
|
|
@@ -187,55 +274,86 @@ class HttpClientError extends Failure {
|
|
|
187
274
|
static async fromResponse(response) {
|
|
188
275
|
switch (MediaType.parse(response.headers.get('Content-Type')).normalized) {
|
|
189
276
|
case 'application/failures+json': {
|
|
190
|
-
const data = await response.json();
|
|
277
|
+
const data = await response.json().catch(() => HttpClientError.#unreadable);
|
|
278
|
+
if (data === HttpClientError.#unreadable) {
|
|
279
|
+
return HttpClientError.#undecodable(response);
|
|
280
|
+
}
|
|
281
|
+
if (!Array.isArray(data)) {
|
|
282
|
+
return HttpClientError.#undecodable(response, 'as a failures array');
|
|
283
|
+
}
|
|
191
284
|
const message = `${response.status} ${response.statusText}: ${data.length} failures`;
|
|
192
285
|
return new HttpClientError(message, response.status, data);
|
|
193
286
|
}
|
|
194
287
|
case 'application/problem+json': {
|
|
195
|
-
const data = await response.json();
|
|
288
|
+
const data = await response.json().catch(() => HttpClientError.#unreadable);
|
|
289
|
+
if (data === HttpClientError.#unreadable) {
|
|
290
|
+
return HttpClientError.#undecodable(response);
|
|
291
|
+
}
|
|
292
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
293
|
+
return HttpClientError.#undecodable(response, 'as a problem object');
|
|
294
|
+
}
|
|
196
295
|
const message = `${response.status} ${response.statusText}: ${data.title} ${data.detail}`;
|
|
197
296
|
return new HttpClientError(
|
|
198
297
|
message,
|
|
199
298
|
response.status,
|
|
200
|
-
data.problems || [
|
|
201
|
-
{
|
|
202
|
-
type: 'GENERIC_PROBLEM',
|
|
203
|
-
context: null,
|
|
204
|
-
reason: message,
|
|
205
|
-
details: null,
|
|
206
|
-
},
|
|
207
|
-
],
|
|
299
|
+
data.problems || [HttpClientError.problem('GENERIC_PROBLEM', message)],
|
|
208
300
|
);
|
|
209
301
|
}
|
|
210
302
|
default: {
|
|
211
|
-
|
|
212
|
-
const message = `${response.status} ${response.statusText}: ${text}`;
|
|
213
|
-
return new HttpClientError(message, response.status, [
|
|
214
|
-
{
|
|
215
|
-
type: 'GENERIC_PROBLEM',
|
|
216
|
-
context: null,
|
|
217
|
-
reason: message,
|
|
218
|
-
details: null,
|
|
219
|
-
},
|
|
220
|
-
]);
|
|
303
|
+
return HttpClientError.#generic(response);
|
|
221
304
|
}
|
|
222
305
|
}
|
|
223
306
|
}
|
|
307
|
+
/** marks a body whose json() rejected, telling it apart from a body decoding to json null */
|
|
308
|
+
static #unreadable = Symbol('unreadable body');
|
|
309
|
+
/**
|
|
310
|
+
* A json body that failed to decode, or that decoded to something other than
|
|
311
|
+
* the declared contract, has consumed its stream: there is no text left to
|
|
312
|
+
* embed, the status, the declared media type and the shape are the report.
|
|
313
|
+
* @param {Response} response
|
|
314
|
+
* @param {string} [as] - what the body does not decode as
|
|
315
|
+
*/
|
|
316
|
+
static #undecodable(response, as = 'as json') {
|
|
317
|
+
const mediaType = MediaType.parse(response.headers.get('Content-Type')).normalized;
|
|
318
|
+
const message = `${response.status} ${response.statusText}: the ${mediaType} body does not decode ${as}`;
|
|
319
|
+
return new HttpClientError(message, response.status, [HttpClientError.problem('GENERIC_PROBLEM', message)]);
|
|
320
|
+
}
|
|
321
|
+
static async #generic(response) {
|
|
322
|
+
//a body that cannot be read (the connection cut mid-body) must not
|
|
323
|
+
//masquerade as a connection problem: the response was served, its
|
|
324
|
+
//status is the report
|
|
325
|
+
const text = await response.text().catch(() => null);
|
|
326
|
+
const message =
|
|
327
|
+
text === null
|
|
328
|
+
? `${response.status} ${response.statusText}: the body could not be read`
|
|
329
|
+
: `${response.status} ${response.statusText}: ${text}`;
|
|
330
|
+
return new HttpClientError(message, response.status, [HttpClientError.problem('GENERIC_PROBLEM', message)]);
|
|
331
|
+
}
|
|
224
332
|
}
|
|
225
333
|
|
|
334
|
+
const metaContent = (name) =>
|
|
335
|
+
globalThis.document?.querySelector(`meta[name="${name}"]`)?.getAttribute('content') ?? undefined;
|
|
336
|
+
|
|
226
337
|
/**
|
|
227
338
|
* @implements {HttpInterceptor}
|
|
228
339
|
*/
|
|
340
|
+
/**
|
|
341
|
+
* Sends the csrf header named by the page's `_csrf_header` meta, with the token
|
|
342
|
+
* from `_csrf`. Both are read per request, so metas replaced after the client
|
|
343
|
+
* was built are honoured, and the header is sent to the page's own origin only.
|
|
344
|
+
*/
|
|
229
345
|
class CsrfTokenInterceptor {
|
|
230
|
-
#k;
|
|
231
|
-
#v;
|
|
232
|
-
constructor() {
|
|
233
|
-
this.#k = document.querySelector("meta[name='_csrf_header']")?.getAttribute('content');
|
|
234
|
-
this.#v = document.querySelector("meta[name='_csrf']")?.getAttribute('content');
|
|
235
|
-
}
|
|
236
346
|
async intercept(url, request, chain) {
|
|
237
|
-
|
|
238
|
-
|
|
347
|
+
//the token is the page's own: it travels to the page's origin only, and it
|
|
348
|
+
//is read at request time, so metas landed after the client was built (a
|
|
349
|
+
//login flow) are honored without a rebuild
|
|
350
|
+
if (url.origin !== (globalThis.window?.location?.origin ?? url.origin)) {
|
|
351
|
+
return await chain.proceed(url, request);
|
|
352
|
+
}
|
|
353
|
+
const csrfHeader = metaContent('_csrf_header');
|
|
354
|
+
const csrfToken = metaContent('_csrf');
|
|
355
|
+
if (csrfHeader && csrfToken) {
|
|
356
|
+
request.headers.set(csrfHeader, csrfToken);
|
|
239
357
|
}
|
|
240
358
|
return await chain.proceed(url, request);
|
|
241
359
|
}
|
|
@@ -243,6 +361,11 @@ class CsrfTokenInterceptor {
|
|
|
243
361
|
/**
|
|
244
362
|
* @implements {HttpInterceptor}
|
|
245
363
|
*/
|
|
364
|
+
/**
|
|
365
|
+
* Navigates to a login url when a response comes back 401. The promise it
|
|
366
|
+
* returns never settles, so callers keep waiting while the page unloads
|
|
367
|
+
* instead of showing a failure nobody will be present to read.
|
|
368
|
+
*/
|
|
246
369
|
class RedirectOnUnauthorizedInterceptor {
|
|
247
370
|
#redirectUri;
|
|
248
371
|
/**
|
|
@@ -255,12 +378,17 @@ class RedirectOnUnauthorizedInterceptor {
|
|
|
255
378
|
const response = await chain.proceed(url, request);
|
|
256
379
|
if (response.status === 401) {
|
|
257
380
|
window.location.href = this.#redirectUri;
|
|
381
|
+
//the page is navigating away: a promise that never settles keeps the
|
|
382
|
+
//callers' spinners up instead of flashing a failure nobody will read.
|
|
383
|
+
//Where the navigation is blocked (a beforeunload gate), they stay
|
|
384
|
+
//pending until the page actually leaves
|
|
258
385
|
return new Promise(() => {});
|
|
259
386
|
}
|
|
260
387
|
return response;
|
|
261
388
|
}
|
|
262
389
|
}
|
|
263
390
|
|
|
391
|
+
/** Collects the interceptors an HttpClient will run, in the order they are added. */
|
|
264
392
|
class HttpClientBuilder {
|
|
265
393
|
/**
|
|
266
394
|
* @type {HttpInterceptor[]}
|
|
@@ -292,12 +420,21 @@ class HttpClientBuilder {
|
|
|
292
420
|
/**
|
|
293
421
|
* @implements {HttpInterceptor}
|
|
294
422
|
*/
|
|
423
|
+
/** The last interceptor in every chain: the one that performs the request. */
|
|
295
424
|
class HttpCall {
|
|
296
425
|
async intercept(url, request, chain) {
|
|
297
|
-
|
|
426
|
+
try {
|
|
427
|
+
return await fetch(url, request);
|
|
428
|
+
} catch (ex) {
|
|
429
|
+
//the one place a connection problem is a connection problem: the
|
|
430
|
+
//transport itself refused to deliver. Everything above this is code,
|
|
431
|
+
//and code that throws has a different story to tell
|
|
432
|
+
throw HttpClientError.of('CONNECTION_PROBLEM', ex);
|
|
433
|
+
}
|
|
298
434
|
}
|
|
299
435
|
}
|
|
300
436
|
|
|
437
|
+
/** One request's position in the interceptor list: `proceed` runs the next interceptor, the last of which performs the request. */
|
|
301
438
|
class HttpInterceptorChain {
|
|
302
439
|
#interceptors;
|
|
303
440
|
#current;
|
|
@@ -326,6 +463,11 @@ class HttpInterceptorChain {
|
|
|
326
463
|
}
|
|
327
464
|
}
|
|
328
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Performs http requests through a fixed list of interceptors. The verbs
|
|
468
|
+
* return a request builder; `exchange` is the lower-level entry that returns
|
|
469
|
+
* the Response itself without treating an error status as a failure.
|
|
470
|
+
*/
|
|
329
471
|
class HttpClient {
|
|
330
472
|
#interceptors;
|
|
331
473
|
/**
|
|
@@ -354,7 +496,8 @@ class HttpClient {
|
|
|
354
496
|
const is = [...this.#interceptors, ...(interceptors || []), new HttpCall()];
|
|
355
497
|
const chain = new HttpInterceptorChain(is, 0);
|
|
356
498
|
const url = new URL(new Request(uri).url);
|
|
357
|
-
|
|
499
|
+
const request = { ...options, headers: new Headers(options?.headers) };
|
|
500
|
+
return await chain.proceed(url, request);
|
|
358
501
|
}
|
|
359
502
|
/**
|
|
360
503
|
* Creates a request builder.
|
|
@@ -416,7 +559,7 @@ class HttpClient {
|
|
|
416
559
|
}
|
|
417
560
|
|
|
418
561
|
/**
|
|
419
|
-
*
|
|
562
|
+
* Reads the response body as the given type, wrapping a failed read as an UNMARSHALING_PROBLEM.
|
|
420
563
|
* @param {Response} response
|
|
421
564
|
* @param {'text'|'json'|'blob'|'arrayBuffer'} type
|
|
422
565
|
* @returns
|
|
@@ -449,6 +592,12 @@ const rawEntries = (source) => {
|
|
|
449
592
|
return Object.entries(source);
|
|
450
593
|
};
|
|
451
594
|
|
|
595
|
+
/**
|
|
596
|
+
* One request under construction: method, url, parameters, headers and body,
|
|
597
|
+
* with a `fetch*` method per body type. Every configuration method returns the
|
|
598
|
+
* builder, and a `fetch*` rejects with an HttpClientError for any status
|
|
599
|
+
* outside 200-299.
|
|
600
|
+
*/
|
|
452
601
|
class HttpRequestBuilder {
|
|
453
602
|
#client;
|
|
454
603
|
#method;
|
|
@@ -458,6 +607,7 @@ class HttpRequestBuilder {
|
|
|
458
607
|
#body;
|
|
459
608
|
#options;
|
|
460
609
|
#interceptors;
|
|
610
|
+
#fragment;
|
|
461
611
|
/**
|
|
462
612
|
* Creates an HttpRequestBuilder.
|
|
463
613
|
* @param {HttpClient} client
|
|
@@ -466,16 +616,22 @@ class HttpRequestBuilder {
|
|
|
466
616
|
* @returns {HttpRequestBuilder} the builder
|
|
467
617
|
*/
|
|
468
618
|
static create(client, method, uri) {
|
|
469
|
-
|
|
619
|
+
//'/a#frag?p=1' parses as hash '#frag?p=1' with an empty query, and a '?'
|
|
620
|
+
//may appear in a query itself: only the first of each splits
|
|
621
|
+
const hashIndex = uri.indexOf('#');
|
|
622
|
+
const fragment = hashIndex === -1 ? '' : uri.slice(hashIndex);
|
|
623
|
+
const withoutFragment = hashIndex === -1 ? uri : uri.slice(0, hashIndex);
|
|
624
|
+
const queryIndex = withoutFragment.indexOf('?');
|
|
470
625
|
return new HttpRequestBuilder(
|
|
471
626
|
client,
|
|
472
627
|
method,
|
|
473
|
-
|
|
474
|
-
new URLSearchParams(
|
|
628
|
+
queryIndex === -1 ? withoutFragment : withoutFragment.slice(0, queryIndex),
|
|
629
|
+
new URLSearchParams(queryIndex === -1 ? '' : withoutFragment.slice(queryIndex + 1)),
|
|
475
630
|
new Headers(),
|
|
476
631
|
undefined,
|
|
477
632
|
{},
|
|
478
633
|
[],
|
|
634
|
+
fragment,
|
|
479
635
|
);
|
|
480
636
|
}
|
|
481
637
|
/**
|
|
@@ -488,8 +644,9 @@ class HttpRequestBuilder {
|
|
|
488
644
|
* @param {any} body
|
|
489
645
|
* @param {Omit<RequestInit,"headers"|"method"|"body">} options
|
|
490
646
|
* @param {HttpInterceptor[]} interceptors
|
|
647
|
+
* @param {string} [fragment]
|
|
491
648
|
*/
|
|
492
|
-
constructor(client, method, uri, params, headers, body, options, interceptors) {
|
|
649
|
+
constructor(client, method, uri, params, headers, body, options, interceptors, fragment = '') {
|
|
493
650
|
this.#client = client;
|
|
494
651
|
this.#method = method;
|
|
495
652
|
this.#uri = uri;
|
|
@@ -498,6 +655,7 @@ class HttpRequestBuilder {
|
|
|
498
655
|
this.#headers = headers;
|
|
499
656
|
this.#options = options;
|
|
500
657
|
this.#interceptors = interceptors;
|
|
658
|
+
this.#fragment = fragment;
|
|
501
659
|
}
|
|
502
660
|
/**
|
|
503
661
|
* Add all passed headers to the request, overriding existing ones if that key already exists. Null and undefined values cause the key to be removed.
|
|
@@ -544,7 +702,7 @@ class HttpRequestBuilder {
|
|
|
544
702
|
return this;
|
|
545
703
|
}
|
|
546
704
|
/**
|
|
547
|
-
* Adds a query parameter to the request, overriding it if it already exists.
|
|
705
|
+
* Adds a query parameter to the request, overriding it if it already exists. An empty list, or one carrying only null and undefined values, causes the key to be removed; nullish entries among real values are skipped.
|
|
548
706
|
* @param {string} k
|
|
549
707
|
* @param {...string} vs
|
|
550
708
|
* @returns {HttpRequestBuilder} this builder
|
|
@@ -553,10 +711,11 @@ class HttpRequestBuilder {
|
|
|
553
711
|
//overriding, as header, headers and params all do: pass every value in one
|
|
554
712
|
//call to get a multi valued parameter
|
|
555
713
|
this.#params.delete(k);
|
|
556
|
-
|
|
714
|
+
const values = vs.filter((v) => v != null);
|
|
715
|
+
if (values.length === 0) {
|
|
557
716
|
return this;
|
|
558
717
|
}
|
|
559
|
-
for (const v of
|
|
718
|
+
for (const v of values) {
|
|
560
719
|
this.#params.append(k, v);
|
|
561
720
|
}
|
|
562
721
|
return this;
|
|
@@ -579,8 +738,12 @@ class HttpRequestBuilder {
|
|
|
579
738
|
* @returns {HttpRequestBuilder} this builder
|
|
580
739
|
*/
|
|
581
740
|
json(body) {
|
|
741
|
+
if (body === undefined) {
|
|
742
|
+
return this;
|
|
743
|
+
}
|
|
744
|
+
const serialized = JSON.stringify(body);
|
|
582
745
|
this.#headers.set('Content-Type', 'application/json');
|
|
583
|
-
this.#body =
|
|
746
|
+
this.#body = serialized;
|
|
584
747
|
return this;
|
|
585
748
|
}
|
|
586
749
|
/**
|
|
@@ -618,7 +781,7 @@ class HttpRequestBuilder {
|
|
|
618
781
|
}
|
|
619
782
|
/**
|
|
620
783
|
* Adds interceptors to the request.
|
|
621
|
-
* @param {[HttpInterceptor]} is - the interceptor to be
|
|
784
|
+
* @param {[HttpInterceptor]} is - the interceptor to be registered
|
|
622
785
|
* @returns {HttpRequestBuilder} this builder
|
|
623
786
|
*/
|
|
624
787
|
interceptors(is) {
|
|
@@ -629,7 +792,7 @@ class HttpRequestBuilder {
|
|
|
629
792
|
}
|
|
630
793
|
/**
|
|
631
794
|
* Adds an interceptor to the request.
|
|
632
|
-
* @param {HttpInterceptor} i - the interceptor to be
|
|
795
|
+
* @param {HttpInterceptor} i - the interceptor to be registered
|
|
633
796
|
* @returns {HttpRequestBuilder} this builder
|
|
634
797
|
*/
|
|
635
798
|
interceptor(i) {
|
|
@@ -641,29 +804,22 @@ class HttpRequestBuilder {
|
|
|
641
804
|
* @returns {Promise<Response>} the response
|
|
642
805
|
*/
|
|
643
806
|
async exchange() {
|
|
644
|
-
const
|
|
807
|
+
const query = this.#params.size ? `?${this.#params}` : '';
|
|
645
808
|
const opts = {
|
|
646
809
|
...this.#options,
|
|
647
810
|
headers: this.#headers,
|
|
648
811
|
method: this.#method,
|
|
649
812
|
body: this.#body,
|
|
650
813
|
};
|
|
651
|
-
return await this.#client.exchange(uri
|
|
814
|
+
return await this.#client.exchange(`${this.#uri}${query}${this.#fragment}`, opts, this.#interceptors);
|
|
652
815
|
}
|
|
653
816
|
/**
|
|
654
|
-
* Performs an HTTP exchange using the configured client request, and
|
|
817
|
+
* Performs an HTTP exchange using the configured client request, and interceptors throwing a failure when response status is not in the 200-299 range.
|
|
655
818
|
* @returns {Promise<Response>} the response
|
|
656
819
|
*/
|
|
657
820
|
async fetch() {
|
|
658
|
-
const uri = this.#params.size ? `${this.#uri}?${this.#params}` : this.#uri;
|
|
659
|
-
const opts = {
|
|
660
|
-
...this.#options,
|
|
661
|
-
headers: this.#headers,
|
|
662
|
-
method: this.#method,
|
|
663
|
-
body: this.#body,
|
|
664
|
-
};
|
|
665
821
|
try {
|
|
666
|
-
const response = await this
|
|
822
|
+
const response = await this.exchange();
|
|
667
823
|
if (!response.ok) {
|
|
668
824
|
throw await HttpClientError.fromResponse(response);
|
|
669
825
|
}
|
|
@@ -672,11 +828,15 @@ class HttpRequestBuilder {
|
|
|
672
828
|
if (ex instanceof Failure) {
|
|
673
829
|
throw ex;
|
|
674
830
|
}
|
|
675
|
-
|
|
831
|
+
//fetch() answers a Failure whatever happened, so a caller reading
|
|
832
|
+
//`problems` never has to test the shape first. What reaches here is
|
|
833
|
+
//not the transport, which labels its own failure below the chain: it
|
|
834
|
+
//is a throw from the chain's own code, carried as the cause
|
|
835
|
+
throw HttpClientError.of('UNEXPECTED_PROBLEM', ex);
|
|
676
836
|
}
|
|
677
837
|
}
|
|
678
838
|
/**
|
|
679
|
-
* Performs an HTTP exchange using the configured client request, and
|
|
839
|
+
* Performs an HTTP exchange using the configured client request, and interceptors throwing a failure when response status is not in the 200-299 range.
|
|
680
840
|
* @returns {Promise<string>} the response body, as text
|
|
681
841
|
*/
|
|
682
842
|
async fetchText() {
|
|
@@ -684,15 +844,20 @@ class HttpRequestBuilder {
|
|
|
684
844
|
return await unmarshal(response, 'text');
|
|
685
845
|
}
|
|
686
846
|
/**
|
|
687
|
-
* Performs an HTTP exchange using the configured client request, and
|
|
847
|
+
* Performs an HTTP exchange using the configured client request, and interceptors throwing a failure when response status is not in the 200-299 range.
|
|
848
|
+
* A 204 yields null without reading the body; any other empty body is an unmarshaling failure.
|
|
688
849
|
* @returns {Promise<any>} the response body, deserialized as JSON
|
|
689
850
|
*/
|
|
690
851
|
async fetchJson() {
|
|
691
852
|
const response = await this.fetch();
|
|
853
|
+
if (response.status === 204) {
|
|
854
|
+
//a 204 declares no content: there is no body to decode
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
692
857
|
return await unmarshal(response, 'json');
|
|
693
858
|
}
|
|
694
859
|
/**
|
|
695
|
-
* Performs an HTTP exchange using the configured client request, and
|
|
860
|
+
* Performs an HTTP exchange using the configured client request, and interceptors throwing a failure when response status is not in the 200-299 range.
|
|
696
861
|
* @returns {Promise<Blob>} the response body, as a Blob
|
|
697
862
|
*/
|
|
698
863
|
async fetchBlob() {
|
|
@@ -700,7 +865,7 @@ class HttpRequestBuilder {
|
|
|
700
865
|
return await unmarshal(response, 'blob');
|
|
701
866
|
}
|
|
702
867
|
/**
|
|
703
|
-
* Performs an HTTP exchange using the configured client request, and
|
|
868
|
+
* Performs an HTTP exchange using the configured client request, and interceptors throwing a failure when response status is not in the 200-299 range.
|
|
704
869
|
* @returns {Promise<ArrayBuffer>} the response body, as an ArrayBuffer
|
|
705
870
|
*/
|
|
706
871
|
async fetchArrayBuffer() {
|
|
@@ -709,6 +874,7 @@ class HttpRequestBuilder {
|
|
|
709
874
|
}
|
|
710
875
|
}
|
|
711
876
|
|
|
877
|
+
/** Builds a multipart body: text fields, json parts, and single or repeated blobs. */
|
|
712
878
|
class HttpMultipartRequestCustomizer {
|
|
713
879
|
#formData;
|
|
714
880
|
/**
|
|
@@ -770,5 +936,5 @@ class HttpMultipartRequestCustomizer {
|
|
|
770
936
|
}
|
|
771
937
|
}
|
|
772
938
|
|
|
773
|
-
export { Base64, Failure, Hex, HttpClient, HttpClientError, MediaType };
|
|
939
|
+
export { Base64, Failure, Hex, HttpClient, HttpClientBuilder, HttpClientError, HttpInterceptorChain, HttpMultipartRequestCustomizer, HttpRequestBuilder, MediaType };
|
|
774
940
|
//# sourceMappingURL=httpc.mjs.map
|