@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.
Files changed (50) hide show
  1. package/LICENSE.md +7 -0
  2. package/README.md +88 -0
  3. package/dist/client-errors.iife.js +30 -9
  4. package/dist/client-errors.iife.js.map +1 -1
  5. package/dist/client-errors.iife.min.js +1 -1
  6. package/dist/client-errors.iife.min.js.map +1 -1
  7. package/dist/custom-elements.json +1502 -408
  8. package/dist/fml.css +21 -10
  9. package/dist/fml.css.map +1 -1
  10. package/dist/fml.d.mts +3 -1299
  11. package/dist/fml.iife.js +5304 -2270
  12. package/dist/fml.iife.js.map +1 -1
  13. package/dist/fml.iife.min.js +1 -1
  14. package/dist/fml.iife.min.js.map +1 -1
  15. package/dist/fml.min.mjs +1 -1
  16. package/dist/fml.min.mjs.map +1 -1
  17. package/dist/fml.mjs +6 -8694
  18. package/dist/fml.mjs.map +1 -1
  19. package/dist/ftl.d.mts +433 -72
  20. package/dist/ftl.iife.js +1313 -807
  21. package/dist/ftl.iife.js.map +1 -1
  22. package/dist/ftl.iife.min.js +1 -1
  23. package/dist/ftl.iife.min.js.map +1 -1
  24. package/dist/ftl.min.mjs +1 -1
  25. package/dist/ftl.min.mjs.map +1 -1
  26. package/dist/ftl.mjs +1312 -808
  27. package/dist/ftl.mjs.map +1 -1
  28. package/dist/ful.css +21 -10
  29. package/dist/ful.css.map +1 -1
  30. package/dist/ful.d.mts +806 -257
  31. package/dist/ful.iife.js +3697 -1356
  32. package/dist/ful.iife.js.map +1 -1
  33. package/dist/ful.iife.min.js +1 -1
  34. package/dist/ful.iife.min.js.map +1 -1
  35. package/dist/ful.min.mjs +1 -1
  36. package/dist/ful.min.mjs.map +1 -1
  37. package/dist/ful.mjs +3686 -1356
  38. package/dist/ful.mjs.map +1 -1
  39. package/dist/httpc.d.mts +114 -19
  40. package/dist/httpc.iife.js +253 -83
  41. package/dist/httpc.iife.js.map +1 -1
  42. package/dist/httpc.iife.min.js +1 -1
  43. package/dist/httpc.iife.min.js.map +1 -1
  44. package/dist/httpc.min.mjs +1 -1
  45. package/dist/httpc.min.mjs.map +1 -1
  46. package/dist/httpc.mjs +250 -84
  47. package/dist/httpc.mjs.map +1 -1
  48. package/dist/vscode.html-custom-data.json +607 -65
  49. package/dist/web-types.json +1471 -376
  50. 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
- let nbytes = Math.floor(str.length * 0.75);
50
- for (let i = 0; i !== str.length; ++i) {
51
- if (str[str.length - i - 1] !== '=') {
52
- break;
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(str.charAt(si++));
65
- const v2 = d.indexOf(str.charAt(si++));
66
- const v3 = d.indexOf(str.charAt(si++));
67
- const v4 = d.indexOf(str.charAt(si++));
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
- return new MediaType(ptype.toLowerCase(), psubtype?.toLowerCase());
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
- return new HttpClientError(
169
- cause.message,
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
- const text = await response.text();
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
- if (this.#k && this.#v) {
238
- request.headers.set(this.#k, this.#v);
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
- return await fetch(url, request);
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
- return await chain.proceed(url, options ?? {});
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
- const [baseUri, queryString = ''] = uri.split('?');
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
- baseUri,
474
- new URLSearchParams(queryString),
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. Empty vs, or a single null or undefined value cause the key to be removed.
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
- if (vs.length === 0 || vs[0] == null) {
714
+ const values = vs.filter((v) => v != null);
715
+ if (values.length === 0) {
557
716
  return this;
558
717
  }
559
- for (const v of vs) {
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 = JSON.stringify(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 regisered
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 regisered
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 uri = this.#params.size ? `${this.#uri}?${this.#params}` : this.#uri;
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, opts, this.#interceptors);
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 interceptos throwing a failure when response status is not in the 200-299 range.
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.#client.exchange(uri, opts, this.#interceptors);
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
- throw HttpClientError.of('CONNECTION_PROBLEM', ex);
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 interceptos throwing a failure when response status is not in the 200-299 range.
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 interceptos throwing a failure when response status is not in the 200-299 range.
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 interceptos throwing a failure when response status is not in the 200-299 range.
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 interceptos throwing a failure when response status is not in the 200-299 range.
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