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