@optionfactory/fml 9.0.0-rc3 → 9.0.0-rc31

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 (44) hide show
  1. package/README.md +16 -88
  2. package/dist/client-errors.iife.js +40 -49
  3. package/dist/client-errors.iife.js.map +1 -1
  4. package/dist/client-errors.iife.min.js +1 -1
  5. package/dist/client-errors.iife.min.js.map +1 -1
  6. package/dist/custom-elements.json +491 -51
  7. package/dist/fml.css +25 -20
  8. package/dist/fml.css.map +1 -1
  9. package/dist/fml.iife.js +5866 -2563
  10. package/dist/fml.iife.js.map +1 -1
  11. package/dist/fml.iife.min.js +1 -1
  12. package/dist/fml.iife.min.js.map +1 -1
  13. package/dist/ftl.d.mts +425 -231
  14. package/dist/ftl.iife.js +774 -1044
  15. package/dist/ftl.iife.js.map +1 -1
  16. package/dist/ftl.iife.min.js +1 -1
  17. package/dist/ftl.iife.min.js.map +1 -1
  18. package/dist/ftl.min.mjs +1 -1
  19. package/dist/ftl.min.mjs.map +1 -1
  20. package/dist/ftl.mjs +774 -1044
  21. package/dist/ftl.mjs.map +1 -1
  22. package/dist/ful.css +25 -20
  23. package/dist/ful.css.map +1 -1
  24. package/dist/ful.d.mts +3048 -556
  25. package/dist/ful.iife.js +4977 -1395
  26. package/dist/ful.iife.js.map +1 -1
  27. package/dist/ful.iife.min.js +1 -1
  28. package/dist/ful.iife.min.js.map +1 -1
  29. package/dist/ful.min.mjs +1 -1
  30. package/dist/ful.min.mjs.map +1 -1
  31. package/dist/ful.mjs +4975 -1396
  32. package/dist/ful.mjs.map +1 -1
  33. package/dist/httpc.d.mts +143 -123
  34. package/dist/httpc.iife.js +163 -175
  35. package/dist/httpc.iife.js.map +1 -1
  36. package/dist/httpc.iife.min.js +1 -1
  37. package/dist/httpc.iife.min.js.map +1 -1
  38. package/dist/httpc.min.mjs +1 -1
  39. package/dist/httpc.min.mjs.map +1 -1
  40. package/dist/httpc.mjs +163 -175
  41. package/dist/httpc.mjs.map +1 -1
  42. package/dist/vscode.html-custom-data.json +239 -39
  43. package/dist/web-types.json +498 -51
  44. package/package.json +2 -2
package/dist/httpc.mjs CHANGED
@@ -1,4 +1,7 @@
1
1
  /**
2
+ * One problem of a failure: its `type` (a code such as `FIELD_ERROR` or
3
+ * `GENERIC_PROBLEM`), the `context` naming the field it belongs to or null, the
4
+ * human `reason`, and optional `details`.
2
5
  * @typedef {{ type: string; context: string?; reason: string; details: any?; }} Problem
3
6
  */
4
7
  /**
@@ -8,10 +11,9 @@
8
11
  */
9
12
  class Failure extends Error {
10
13
  /**
11
- *
12
14
  * @param {string} message
13
15
  * @param {Problem[]} problems
14
- * @param {*} cause
16
+ * @param {*} [cause]
15
17
  */
16
18
  constructor(message, problems, cause) {
17
19
  super(message, { cause });
@@ -28,6 +30,7 @@ class Failure extends Error {
28
30
  return new Failure(this.message, Failure.dropProblemsContext(this.problems, prefix), this);
29
31
  }
30
32
  /**
33
+ * Copies of the problems, each context losing the prefix where it starts with it.
31
34
  * @param {Problem[]} problems
32
35
  * @param {string} prefix
33
36
  * @returns {Problem[]}
@@ -38,21 +41,6 @@ class Failure extends Error {
38
41
  return { type, context: nctx, reason, details };
39
42
  });
40
43
  }
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
- }
56
44
  }
57
45
 
58
46
  /**
@@ -62,6 +50,10 @@ class Failure extends Error {
62
50
  * of corrupting silently.
63
51
  */
64
52
  class Base64 {
53
+ /** The RFC 4648 alphabet, with `+` and `/`. */
54
+ static STANDARD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
55
+ /** The RFC 4648 url and filename safe alphabet, with `-` and `_`. */
56
+ static URL_SAFE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
65
57
  /**
66
58
  * @param {ArrayBuffer} arrayBuffer
67
59
  * @param {string} [dialect] one of Base64.STANDARD or Base64.URL_SAFE, URL_SAFE by default
@@ -87,14 +79,14 @@ class Base64 {
87
79
  return res;
88
80
  }
89
81
  /**
90
- * @param {string} str
82
+ * @param {string} str padded or unpadded
91
83
  * @param {string} [dialect] one of Base64.STANDARD or Base64.URL_SAFE, URL_SAFE by default
92
84
  * @returns {ArrayBuffer}
85
+ * @throws on padding anywhere but the tail or longer than two, on an
86
+ * impossible length, and on a character outside the dialect
93
87
  */
94
88
  static decode(str, dialect) {
95
89
  const d = dialect || Base64.URL_SAFE;
96
- //padding belongs at the tail only, two at most, and nothing outside the
97
- //dialect decodes: reject instead of corrupting silently
98
90
  let end = str.length;
99
91
  while (end > 0 && str.charAt(end - 1) === '=') {
100
92
  --end;
@@ -116,9 +108,6 @@ class Base64 {
116
108
 
117
109
  let vi = 0;
118
110
  let si = 0;
119
- //every group yields three bytes, but a padded input stops short of the last
120
- //one or two: writing them anyway would rely on typed arrays dropping writes
121
- //past their length
122
111
  while (vi < nbytes) {
123
112
  const v1 = d.indexOf(unpadded.charAt(si++));
124
113
  const v2 = d.indexOf(unpadded.charAt(si++));
@@ -139,16 +128,14 @@ class Base64 {
139
128
  }
140
129
  }
141
130
 
142
- Base64.STANDARD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
143
- Base64.URL_SAFE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
144
-
145
131
  /**
146
132
  * Hex encoding and decoding over byte sequences, lowercase by default.
147
133
  */
148
134
  class Hex {
149
135
  /**
150
- * @param {string} hex
136
+ * @param {string} hex either case
151
137
  * @returns {Uint8Array}
138
+ * @throws on an odd length or a character that is not a hex digit
152
139
  */
153
140
  static decode(hex) {
154
141
  if (hex.length % 2 !== 0) {
@@ -165,12 +152,20 @@ class Hex {
165
152
  });
166
153
  }
167
154
  /**
168
- * @param {Iterable<number>} bytes
169
- * @param {boolean} [upper]
170
- * @returns {string}
155
+ * @param {Iterable<number>|ArrayBuffer|ArrayBufferView} bytes octets, or
156
+ * a buffer or view read as its bytes
157
+ * @param {boolean} [upper] uppercase digits instead of lowercase
158
+ * @returns {string} two digits per byte
171
159
  */
172
160
  static encode(bytes, upper) {
173
- return Array.from(bytes)
161
+ let octets = /** @type {Iterable<number>} */ (bytes);
162
+ if (bytes instanceof ArrayBuffer) {
163
+ octets = new Uint8Array(bytes);
164
+ }
165
+ if (ArrayBuffer.isView(bytes)) {
166
+ octets = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
167
+ }
168
+ return Array.from(octets)
174
169
  .map((b) => b.toString(16))
175
170
  .map((b) => (upper ? b.toUpperCase() : b))
176
171
  .map((o) => o.padStart(2, '0'))
@@ -186,19 +181,22 @@ class MediaType {
186
181
  this.#type = type;
187
182
  this.#subtype = subtype;
188
183
  }
184
+ /** @returns {string} `type/subtype`, lowercase */
189
185
  get normalized() {
190
186
  return `${this.#type}/${this.#subtype}`;
191
187
  }
188
+ /** @returns {string} */
192
189
  get type() {
193
190
  return this.#type;
194
191
  }
192
+ /** @returns {string} */
195
193
  get subtype() {
196
194
  return this.#subtype;
197
195
  }
198
196
  /**
199
197
  * Parses a Content-Type header value into its type/subtype pair, dropping any parameter.
200
198
  * @param {string|null|undefined} v
201
- * @returns
199
+ * @returns {MediaType} `unknown/unknown` for a missing or malformed value
202
200
  */
203
201
  static parse(v) {
204
202
  if (!v) {
@@ -248,28 +246,36 @@ class HttpClientError extends Failure {
248
246
  return new HttpClientError(this.message, this.status, Failure.dropProblemsContext(this.problems, prefix), this);
249
247
  }
250
248
  /**
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.
249
+ * A problem with no context and no details.
253
250
  * @param {string} type
254
251
  * @param {string} reason
252
+ * @returns {{ type: string; context: null; reason: string; details: null; }}
255
253
  */
256
254
  static problem(type, reason) {
257
255
  return { type, context: null, reason, details: null };
258
256
  }
259
257
  /**
260
- * Creates a client failure carrying no status, wrapping the cause and its message.
258
+ * A failure with status 0 and one problem of the given type, whose reason is
259
+ * the cause's message.
261
260
  * @param {string} type
262
261
  * @param {any} cause
263
- * @returns
262
+ * @returns {HttpClientError}
264
263
  */
265
264
  static of(type, cause) {
266
265
  const reason = String(cause?.message ?? cause ?? 'unknown failure');
267
266
  return new HttpClientError(reason, 0, [HttpClientError.problem(type, reason)], cause);
268
267
  }
269
268
  /**
270
- * Creates an HttpClientError from a Response.
271
- * @param {Response} response
272
- * @returns an HttpClientError
269
+ * The failure an error response stands for, keeping its status. An
270
+ * `application/failures+json` body is the problems list; an
271
+ * `application/problem+json` body contributes its non-empty `problems`
272
+ * array, or one GENERIC_PROBLEM made of whichever of its title and detail
273
+ * it has. Any other body is one GENERIC_PROBLEM carrying the body text. A
274
+ * json body that does not decode, or decodes to the wrong shape or to an
275
+ * empty failures array, and a body that cannot be read at all, each
276
+ * become one GENERIC_PROBLEM saying so.
277
+ * @param {Response} response consumed
278
+ * @returns {Promise<HttpClientError>}
273
279
  */
274
280
  static async fromResponse(response) {
275
281
  switch (MediaType.parse(response.headers.get('Content-Type')).normalized) {
@@ -279,7 +285,10 @@ class HttpClientError extends Failure {
279
285
  return HttpClientError.#undecodable(response);
280
286
  }
281
287
  if (!Array.isArray(data)) {
282
- return HttpClientError.#undecodable(response, 'as a failures array');
288
+ return HttpClientError.#undecodable(response, 'does not decode as a failures array');
289
+ }
290
+ if (data.length === 0) {
291
+ return HttpClientError.#undecodable(response, 'carries no failures');
283
292
  }
284
293
  const message = `${response.status} ${response.statusText}: ${data.length} failures`;
285
294
  return new HttpClientError(message, response.status, data);
@@ -290,13 +299,18 @@ class HttpClientError extends Failure {
290
299
  return HttpClientError.#undecodable(response);
291
300
  }
292
301
  if (typeof data !== 'object' || data === null || Array.isArray(data)) {
293
- return HttpClientError.#undecodable(response, 'as a problem object');
302
+ return HttpClientError.#undecodable(response, 'does not decode as a problem object');
294
303
  }
295
- const message = `${response.status} ${response.statusText}: ${data.title} ${data.detail}`;
304
+ const text = [data.title, data.detail].filter((part) => part !== undefined && part !== null).join(' ');
305
+ const message = text
306
+ ? `${response.status} ${response.statusText}: ${text}`
307
+ : `${response.status} ${response.statusText}`;
296
308
  return new HttpClientError(
297
309
  message,
298
310
  response.status,
299
- data.problems || [HttpClientError.problem('GENERIC_PROBLEM', message)],
311
+ Array.isArray(data.problems) && data.problems.length > 0
312
+ ? data.problems
313
+ : [HttpClientError.problem('GENERIC_PROBLEM', message)],
300
314
  );
301
315
  }
302
316
  default: {
@@ -304,24 +318,17 @@ class HttpClientError extends Failure {
304
318
  }
305
319
  }
306
320
  }
307
- /** marks a body whose json() rejected, telling it apart from a body decoding to json null */
308
321
  static #unreadable = Symbol('unreadable body');
309
322
  /**
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
323
  * @param {Response} response
314
- * @param {string} [as] - what the body does not decode as
324
+ * @param {string} [flaw] what is wrong with the body
315
325
  */
316
- static #undecodable(response, as = 'as json') {
326
+ static #undecodable(response, flaw = 'does not decode as json') {
317
327
  const mediaType = MediaType.parse(response.headers.get('Content-Type')).normalized;
318
- const message = `${response.status} ${response.statusText}: the ${mediaType} body does not decode ${as}`;
328
+ const message = `${response.status} ${response.statusText}: the ${mediaType} body ${flaw}`;
319
329
  return new HttpClientError(message, response.status, [HttpClientError.problem('GENERIC_PROBLEM', message)]);
320
330
  }
321
331
  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
332
  const text = await response.text().catch(() => null);
326
333
  const message =
327
334
  text === null
@@ -334,19 +341,14 @@ class HttpClientError extends Failure {
334
341
  const metaContent = (name) =>
335
342
  globalThis.document?.querySelector(`meta[name="${name}"]`)?.getAttribute('content') ?? undefined;
336
343
 
337
- /**
338
- * @implements {HttpInterceptor}
339
- */
340
344
  /**
341
345
  * Sends the csrf header named by the page's `_csrf_header` meta, with the token
342
346
  * from `_csrf`. Both are read per request, so metas replaced after the client
343
347
  * was built are honoured, and the header is sent to the page's own origin only.
348
+ * @implements {HttpInterceptor}
344
349
  */
345
350
  class CsrfTokenInterceptor {
346
351
  async intercept(url, request, chain) {
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
352
  if (url.origin !== (globalThis.window?.location?.origin ?? url.origin)) {
351
353
  return await chain.proceed(url, request);
352
354
  }
@@ -358,13 +360,12 @@ class CsrfTokenInterceptor {
358
360
  return await chain.proceed(url, request);
359
361
  }
360
362
  }
361
- /**
362
- * @implements {HttpInterceptor}
363
- */
364
363
  /**
365
364
  * Navigates to a login url when a response comes back 401. The promise it
366
365
  * returns never settles, so callers keep waiting while the page unloads
367
- * instead of showing a failure nobody will be present to read.
366
+ * instead of showing a failure; where the navigation is blocked, as by a
367
+ * beforeunload prompt, they stay pending until the page leaves.
368
+ * @implements {HttpInterceptor}
368
369
  */
369
370
  class RedirectOnUnauthorizedInterceptor {
370
371
  #redirectUri;
@@ -378,10 +379,6 @@ class RedirectOnUnauthorizedInterceptor {
378
379
  const response = await chain.proceed(url, request);
379
380
  if (response.status === 401) {
380
381
  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
385
382
  return new Promise(() => {});
386
383
  }
387
384
  return response;
@@ -397,38 +394,48 @@ class HttpClientBuilder {
397
394
  constructor() {
398
395
  this.#interceptors = [];
399
396
  }
397
+ /**
398
+ * Adds the CsrfTokenInterceptor.
399
+ * @returns {HttpClientBuilder} this builder
400
+ */
400
401
  withCsrfToken() {
401
402
  this.#interceptors.push(new CsrfTokenInterceptor());
402
403
  return this;
403
404
  }
405
+ /**
406
+ * Adds the RedirectOnUnauthorizedInterceptor.
407
+ * @param {string} redirectUri where a 401 navigates to
408
+ * @returns {HttpClientBuilder} this builder
409
+ */
404
410
  withRedirectOnUnauthorized(redirectUri) {
405
411
  this.#interceptors.push(new RedirectOnUnauthorizedInterceptor(redirectUri));
406
412
  return this;
407
413
  }
408
414
  /**
415
+ * Adds interceptors, run in the order given after the ones added before.
409
416
  * @param {...HttpInterceptor} interceptors
417
+ * @returns {HttpClientBuilder} this builder
410
418
  */
411
419
  withInterceptors(...interceptors) {
412
420
  this.#interceptors.push(...interceptors);
413
421
  return this;
414
422
  }
423
+ /** @returns {HttpClient} a client running the interceptors added so far */
415
424
  build() {
416
- return new HttpClient(this.#interceptors);
425
+ return new HttpClient([...this.#interceptors]);
417
426
  }
418
427
  }
419
428
 
420
429
  /**
430
+ * The last interceptor in every chain, performing the request with fetch. A
431
+ * fetch rejection becomes an HttpClientError with a CONNECTION_PROBLEM.
421
432
  * @implements {HttpInterceptor}
422
433
  */
423
- /** The last interceptor in every chain: the one that performs the request. */
424
434
  class HttpCall {
425
435
  async intercept(url, request, chain) {
426
436
  try {
427
437
  return await fetch(url, request);
428
438
  } 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
439
  throw HttpClientError.of('CONNECTION_PROBLEM', ex);
433
440
  }
434
441
  }
@@ -439,19 +446,18 @@ class HttpInterceptorChain {
439
446
  #interceptors;
440
447
  #current;
441
448
  /**
442
- *
443
449
  * @param {HttpInterceptor[]} interceptors
444
- * @param {number} current
450
+ * @param {number} current the index of the interceptor `proceed` runs
445
451
  */
446
452
  constructor(interceptors, current) {
447
453
  this.#interceptors = interceptors;
448
454
  this.#current = current;
449
455
  }
450
456
  /**
451
- *
457
+ * Runs the next interceptor.
452
458
  * @param {URL} url
453
459
  * @param {RequestInit} request
454
- * @returns {Promise<Response>} the response
460
+ * @returns {Promise<Response>}
455
461
  */
456
462
  async proceed(url, request) {
457
463
  const interceptor = this.#interceptors[this.#current];
@@ -485,12 +491,14 @@ class HttpClient {
485
491
  this.#interceptors = interceptors || [];
486
492
  }
487
493
  /**
488
- * Performs an HTTP exchange.
489
- * @async
490
- * @param {string} uri - the (possibly relative) request url
491
- * @param {RequestInit|undefined} options - fetch options
492
- * @param {HttpInterceptor[]|undefined} interceptors - the HttpInterceptors to be registered for this exchange.
493
- * @returns {Promise<Response>} the response
494
+ * Performs an HTTP exchange through the client's interceptors, then the
495
+ * given ones.
496
+ * @param {string} uri the request url, relative to the page's
497
+ * @param {RequestInit|undefined} options fetch options
498
+ * @param {HttpInterceptor[]|undefined} interceptors run for this exchange only
499
+ * @returns {Promise<Response>} the response, whatever its status; rejecting
500
+ * with an HttpClientError CONNECTION_PROBLEM when the transport fails, or
501
+ * with what an interceptor throws
494
502
  */
495
503
  async exchange(uri, options, interceptors) {
496
504
  const is = [...this.#interceptors, ...(interceptors || []), new HttpCall()];
@@ -500,58 +508,58 @@ class HttpClient {
500
508
  return await chain.proceed(url, request);
501
509
  }
502
510
  /**
503
- * Creates a request builder.
504
- * @param {string} method - the HTTP method to be used
505
- * @param {string} uri - the (possibly relative) request url
506
- * @returns {HttpRequestBuilder} the request builder
511
+ * Starts a request with any method.
512
+ * @param {string} method
513
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
514
+ * @returns {HttpRequestBuilder}
507
515
  */
508
516
  request(method, uri) {
509
517
  return HttpRequestBuilder.create(this, method, uri);
510
518
  }
511
519
  /**
512
- * Creates a request builder.
513
- * @param {string} uri - the (possibly relative) request url
514
- * @returns {HttpRequestBuilder} the request builder
520
+ * Starts a GET request.
521
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
522
+ * @returns {HttpRequestBuilder}
515
523
  */
516
524
  get(uri) {
517
525
  return HttpRequestBuilder.create(this, 'GET', uri);
518
526
  }
519
527
  /**
520
- * Creates a request builder.
521
- * @param {string} uri - the (possibly relative) request url
522
- * @returns {HttpRequestBuilder} the request builder
528
+ * Starts a HEAD request.
529
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
530
+ * @returns {HttpRequestBuilder}
523
531
  */
524
532
  head(uri) {
525
533
  return HttpRequestBuilder.create(this, 'HEAD', uri);
526
534
  }
527
535
  /**
528
- * Creates a request builder.
529
- * @param {string} uri - the (possibly relative) request url
530
- * @returns {HttpRequestBuilder} the request builder
536
+ * Starts a POST request.
537
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
538
+ * @returns {HttpRequestBuilder}
531
539
  */
532
540
  post(uri) {
533
541
  return HttpRequestBuilder.create(this, 'POST', uri);
534
542
  }
535
543
  /**
536
- * Creates a request builder.
537
- * @param {string} uri - the (possibly relative) request url
538
- * @returns {HttpRequestBuilder} the request builder
544
+ * Starts a PUT request.
545
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
546
+ * @returns {HttpRequestBuilder}
539
547
  */
540
548
  put(uri) {
541
549
  return HttpRequestBuilder.create(this, 'PUT', uri);
542
550
  }
543
551
  /**
544
- * Creates a request builder.
545
- * @param {string} uri - the (possibly relative) request url
546
- * @returns {HttpRequestBuilder} the request builder
552
+ * Starts a PATCH request.
553
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
554
+ * @returns {HttpRequestBuilder}
547
555
  */
548
556
  patch(uri) {
549
557
  return HttpRequestBuilder.create(this, 'PATCH', uri);
550
558
  }
551
559
  /**
552
- * Creates a request builder.
553
- * @param {string} uri - the (possibly relative) request url
554
- * @returns {HttpRequestBuilder} the request builder
560
+ * Starts a DELETE request.
561
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
562
+ * @returns {HttpRequestBuilder}
555
563
  */
556
564
  delete(uri) {
557
565
  return HttpRequestBuilder.create(this, 'DELETE', uri);
@@ -562,7 +570,7 @@ class HttpClient {
562
570
  * Reads the response body as the given type, wrapping a failed read as an UNMARSHALING_PROBLEM.
563
571
  * @param {Response} response
564
572
  * @param {'text'|'json'|'blob'|'arrayBuffer'} type
565
- * @returns
573
+ * @returns {Promise<any>}
566
574
  */
567
575
  const unmarshal = async (response, type) => {
568
576
  try {
@@ -573,9 +581,7 @@ const unmarshal = async (response, type) => {
573
581
  };
574
582
 
575
583
  /**
576
- * Yields the entries of a headers or params initializer preserving nullish values:
577
- * normalizing through `Headers`/`URLSearchParams` first would stringify them to
578
- * "null"/"undefined" instead of removing the key.
584
+ * The entries of a headers or params initializer with nullish values kept as they are.
579
585
  * @param {any} source
580
586
  * @returns {Iterable<[string, any]>}
581
587
  */
@@ -609,15 +615,14 @@ class HttpRequestBuilder {
609
615
  #interceptors;
610
616
  #fragment;
611
617
  /**
612
- * Creates an HttpRequestBuilder.
618
+ * A builder for the url: its query becomes the initial params, split at the
619
+ * first `?`, and its fragment, from the first `#`, is kept for the final url.
613
620
  * @param {HttpClient} client
614
- * @param {string} method - the HTTP method to be used
615
- * @param {string} uri - the (possibly relative) request url
616
- * @returns {HttpRequestBuilder} the builder
621
+ * @param {string} method
622
+ * @param {string} uri
623
+ * @returns {HttpRequestBuilder}
617
624
  */
618
625
  static create(client, method, uri) {
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
626
  const hashIndex = uri.indexOf('#');
622
627
  const fragment = hashIndex === -1 ? '' : uri.slice(hashIndex);
623
628
  const withoutFragment = hashIndex === -1 ? uri : uri.slice(0, hashIndex);
@@ -635,10 +640,9 @@ class HttpRequestBuilder {
635
640
  );
636
641
  }
637
642
  /**
638
- * Creates an HttpRequestBuilder.
639
643
  * @param {HttpClient} client
640
- * @param {string} method - the HTTP method to be used
641
- * @param {string} uri - the (possibly relative) request url
644
+ * @param {string} method
645
+ * @param {string} uri the url without query and fragment
642
646
  * @param {URLSearchParams} params
643
647
  * @param {Headers} headers
644
648
  * @param {any} body
@@ -658,24 +662,20 @@ class HttpRequestBuilder {
658
662
  this.#fragment = fragment;
659
663
  }
660
664
  /**
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.
665
+ * Sets each header, replacing a header of the same name; a null or undefined value removes it.
662
666
  * @param {HeadersInit|Record<string,string|null|undefined>} hs
663
667
  * @returns {HttpRequestBuilder} this builder
664
668
  */
665
669
  headers(hs) {
666
670
  for (const [k, v] of rawEntries(hs)) {
667
- if (v == null) {
668
- this.#headers.delete(k);
669
- } else {
670
- this.#headers.set(k, v);
671
- }
671
+ this.header(k, v);
672
672
  }
673
673
  return this;
674
674
  }
675
675
  /**
676
- * Adds an header to the request, overriding it if it already exists. Null and undefined values cause the key to be removed
676
+ * Sets a header, replacing one of the same name; a null or undefined value removes it.
677
677
  * @param {string} k
678
- * @param {string} v
678
+ * @param {string|null|undefined} v
679
679
  * @returns {HttpRequestBuilder} this builder
680
680
  */
681
681
  header(k, v) {
@@ -687,7 +687,7 @@ class HttpRequestBuilder {
687
687
  return this;
688
688
  }
689
689
  /**
690
- * Add all query parameters to the request, overriding existing ones if that key already exists. Null and undefined values cause the key to be removed
690
+ * Sets each query parameter in place, replacing a parameter of the same name; a null or undefined value removes it.
691
691
  * @param {URLSearchParams|Record<string,string|null|undefined>|string[][]|string} ps
692
692
  * @returns {HttpRequestBuilder} this builder
693
693
  */
@@ -702,20 +702,14 @@ class HttpRequestBuilder {
702
702
  return this;
703
703
  }
704
704
  /**
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.
705
+ * Sets a query parameter to the values given, as repeated entries moved to the end of the query; nullish values are skipped, and none left removes the parameter.
706
706
  * @param {string} k
707
707
  * @param {...string} vs
708
708
  * @returns {HttpRequestBuilder} this builder
709
709
  */
710
710
  param(k, ...vs) {
711
- //overriding, as header, headers and params all do: pass every value in one
712
- //call to get a multi valued parameter
713
711
  this.#params.delete(k);
714
- const values = vs.filter((v) => v != null);
715
- if (values.length === 0) {
716
- return this;
717
- }
718
- for (const v of values) {
712
+ for (const v of vs.filter((v) => v != null)) {
719
713
  this.#params.append(k, v);
720
714
  }
721
715
  return this;
@@ -750,6 +744,7 @@ class HttpRequestBuilder {
750
744
  * Sets the request body as a FormData configured using the callback.
751
745
  * `Content-Type: multipart/form-data` header is automatically added by fetch if not explicitly set.
752
746
  * @param {(c: HttpMultipartRequestCustomizer) => void} callback
747
+ * @returns {HttpRequestBuilder} this builder
753
748
  */
754
749
  multipart(callback) {
755
750
  const formData = new FormData();
@@ -759,7 +754,7 @@ class HttpRequestBuilder {
759
754
  return this;
760
755
  }
761
756
  /**
762
- * Sets a fetch options for the request.
757
+ * Merges fetch options into the request's.
763
758
  * @param {Omit<RequestInit,"headers"|"method"|"body">} kvs
764
759
  * @returns {HttpRequestBuilder} this builder
765
760
  */
@@ -780,8 +775,8 @@ class HttpRequestBuilder {
780
775
  return this;
781
776
  }
782
777
  /**
783
- * Adds interceptors to the request.
784
- * @param {[HttpInterceptor]} is - the interceptor to be registered
778
+ * Adds interceptors run for this request only, after the client's.
779
+ * @param {HttpInterceptor[]} is
785
780
  * @returns {HttpRequestBuilder} this builder
786
781
  */
787
782
  interceptors(is) {
@@ -791,8 +786,8 @@ class HttpRequestBuilder {
791
786
  return this;
792
787
  }
793
788
  /**
794
- * Adds an interceptor to the request.
795
- * @param {HttpInterceptor} i - the interceptor to be registered
789
+ * Adds one interceptor run for this request only, after the client's.
790
+ * @param {HttpInterceptor} i
796
791
  * @returns {HttpRequestBuilder} this builder
797
792
  */
798
793
  interceptor(i) {
@@ -800,8 +795,8 @@ class HttpRequestBuilder {
800
795
  return this;
801
796
  }
802
797
  /**
803
- * Performs an HTTP exchange using the configured client, request and interceptors.
804
- * @returns {Promise<Response>} the response
798
+ * Sends the request and answers the Response, whatever its status.
799
+ * @returns {Promise<Response>} rejecting as `HttpClient.exchange` does
805
800
  */
806
801
  async exchange() {
807
802
  const query = this.#params.size ? `?${this.#params}` : '';
@@ -814,8 +809,12 @@ class HttpRequestBuilder {
814
809
  return await this.#client.exchange(`${this.#uri}${query}${this.#fragment}`, opts, this.#interceptors);
815
810
  }
816
811
  /**
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.
818
- * @returns {Promise<Response>} the response
812
+ * Sends the request and answers the Response when its status is 200-299.
813
+ * Always rejects with an HttpClientError: `HttpClientError.fromResponse`
814
+ * for an error status, CONNECTION_PROBLEM for a transport failure, and
815
+ * UNEXPECTED_PROBLEM, carrying the error as its cause, for anything an
816
+ * interceptor throws that is not already a Failure.
817
+ * @returns {Promise<Response>}
819
818
  */
820
819
  async fetch() {
821
820
  try {
@@ -828,45 +827,40 @@ class HttpRequestBuilder {
828
827
  if (ex instanceof Failure) {
829
828
  throw ex;
830
829
  }
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
830
  throw HttpClientError.of('UNEXPECTED_PROBLEM', ex);
836
831
  }
837
832
  }
838
833
  /**
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.
840
- * @returns {Promise<string>} the response body, as text
834
+ * `fetch()`, then the body as text; a body that cannot be read rejects with UNMARSHALING_PROBLEM.
835
+ * @returns {Promise<string>}
841
836
  */
842
837
  async fetchText() {
843
838
  const response = await this.fetch();
844
839
  return await unmarshal(response, 'text');
845
840
  }
846
841
  /**
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.
849
- * @returns {Promise<any>} the response body, deserialized as JSON
842
+ * `fetch()`, then the body parsed as json. A 204 yields null without reading
843
+ * the body; any other body that does not parse rejects with UNMARSHALING_PROBLEM.
844
+ * @returns {Promise<any>}
850
845
  */
851
846
  async fetchJson() {
852
847
  const response = await this.fetch();
853
848
  if (response.status === 204) {
854
- //a 204 declares no content: there is no body to decode
855
849
  return null;
856
850
  }
857
851
  return await unmarshal(response, 'json');
858
852
  }
859
853
  /**
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.
861
- * @returns {Promise<Blob>} the response body, as a Blob
854
+ * `fetch()`, then the body as a Blob; a body that cannot be read rejects with UNMARSHALING_PROBLEM.
855
+ * @returns {Promise<Blob>}
862
856
  */
863
857
  async fetchBlob() {
864
858
  const response = await this.fetch();
865
859
  return await unmarshal(response, 'blob');
866
860
  }
867
861
  /**
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.
869
- * @returns {Promise<ArrayBuffer>} the response body, as an ArrayBuffer
862
+ * `fetch()`, then the body as an ArrayBuffer; a body that cannot be read rejects with UNMARSHALING_PROBLEM.
863
+ * @returns {Promise<ArrayBuffer>}
870
864
  */
871
865
  async fetchArrayBuffer() {
872
866
  const response = await this.fetch();
@@ -877,10 +871,7 @@ class HttpRequestBuilder {
877
871
  /** Builds a multipart body: text fields, json parts, and single or repeated blobs. */
878
872
  class HttpMultipartRequestCustomizer {
879
873
  #formData;
880
- /**
881
- *
882
- * @param {FormData} formData
883
- */
874
+ /** @param {FormData} formData the body being built */
884
875
  constructor(formData) {
885
876
  this.#formData = formData;
886
877
  }
@@ -888,33 +879,30 @@ class HttpMultipartRequestCustomizer {
888
879
  * Appends a value to the FormData.
889
880
  * @param {string} name
890
881
  * @param {*} value
891
- * @returns this builder
882
+ * @returns {HttpMultipartRequestCustomizer} this builder
892
883
  */
893
884
  field(name, value) {
894
885
  this.#formData.append(name, value);
895
886
  return this;
896
887
  }
897
888
  /**
898
- * Appends a Blob to the FormData.
899
- * If `filename` is omitted, FormData defaults are applied:
900
- * The default filename for Blob objects is "blob";
901
- * The default filename for File objects is the file's filename.
889
+ * Appends a Blob to the FormData. Without a filename a File keeps its own
890
+ * and any other Blob is named "blob".
902
891
  * @param {string} name
903
892
  * @param {Blob} value
904
- * @param {string|undefined} filename
905
- * @returns this builder
893
+ * @param {string} [filename]
894
+ * @returns {HttpMultipartRequestCustomizer} this builder
906
895
  */
907
896
  blob(name, value, filename) {
908
897
  this.#formData.append(name, value, filename);
909
898
  return this;
910
899
  }
911
900
  /**
912
- * Appends multiple Blobs to the FormData with the same name.
913
- * The default filename for Blob objects is "blob";
914
- * The default filename for File objects is the file's filename.
901
+ * Appends each Blob under the same name, a File keeping its own filename and
902
+ * any other Blob named "blob".
915
903
  * @param {string} name
916
904
  * @param {Blob[]} values
917
- * @returns this builder
905
+ * @returns {HttpMultipartRequestCustomizer} this builder
918
906
  */
919
907
  blobs(name, values) {
920
908
  for (const v of values) {
@@ -923,11 +911,11 @@ class HttpMultipartRequestCustomizer {
923
911
  return this;
924
912
  }
925
913
  /**
926
- * Appends a JSON serialized blob to the FormData.
914
+ * Appends the value serialized as an `application/json` blob.
927
915
  * @param {string} name
928
916
  * @param {any} value
929
- * @param {string|undefined} filename
930
- * @returns this builder
917
+ * @param {string} [filename]
918
+ * @returns {HttpMultipartRequestCustomizer} this builder
931
919
  */
932
920
  json(name, value, filename) {
933
921
  const blob = new Blob([JSON.stringify(value)], { type: 'application/json' });