@optionfactory/fml 9.0.0-rc2 → 9.0.0-rc20

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 -89
  2. package/dist/client-errors.iife.js +23 -37
  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 +459 -36
  7. package/dist/fml.css +24 -19
  8. package/dist/fml.css.map +1 -1
  9. package/dist/fml.iife.js +4646 -1779
  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 +395 -224
  14. package/dist/ftl.iife.js +402 -332
  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 +402 -332
  21. package/dist/ftl.mjs.map +1 -1
  22. package/dist/ful.css +24 -19
  23. package/dist/ful.css.map +1 -1
  24. package/dist/ful.d.mts +2801 -519
  25. package/dist/ful.iife.js +4096 -1275
  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 +4093 -1277
  32. package/dist/ful.mjs.map +1 -1
  33. package/dist/httpc.d.mts +139 -121
  34. package/dist/httpc.iife.js +137 -166
  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 +137 -166
  41. package/dist/httpc.mjs.map +1 -1
  42. package/dist/vscode.html-custom-data.json +215 -27
  43. package/dist/web-types.json +463 -36
  44. package/package.json +1 -1
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) {
@@ -166,8 +153,8 @@ class Hex {
166
153
  }
167
154
  /**
168
155
  * @param {Iterable<number>} bytes
169
- * @param {boolean} [upper]
170
- * @returns {string}
156
+ * @param {boolean} [upper] uppercase digits instead of lowercase
157
+ * @returns {string} two digits per byte
171
158
  */
172
159
  static encode(bytes, upper) {
173
160
  return Array.from(bytes)
@@ -186,19 +173,22 @@ class MediaType {
186
173
  this.#type = type;
187
174
  this.#subtype = subtype;
188
175
  }
176
+ /** @returns {string} `type/subtype`, lowercase */
189
177
  get normalized() {
190
178
  return `${this.#type}/${this.#subtype}`;
191
179
  }
180
+ /** @returns {string} */
192
181
  get type() {
193
182
  return this.#type;
194
183
  }
184
+ /** @returns {string} */
195
185
  get subtype() {
196
186
  return this.#subtype;
197
187
  }
198
188
  /**
199
189
  * Parses a Content-Type header value into its type/subtype pair, dropping any parameter.
200
190
  * @param {string|null|undefined} v
201
- * @returns
191
+ * @returns {MediaType} `unknown/unknown` for a missing or malformed value
202
192
  */
203
193
  static parse(v) {
204
194
  if (!v) {
@@ -248,28 +238,35 @@ class HttpClientError extends Failure {
248
238
  return new HttpClientError(this.message, this.status, Failure.dropProblemsContext(this.problems, prefix), this);
249
239
  }
250
240
  /**
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.
241
+ * A problem with no context and no details.
253
242
  * @param {string} type
254
243
  * @param {string} reason
244
+ * @returns {{ type: string; context: null; reason: string; details: null; }}
255
245
  */
256
246
  static problem(type, reason) {
257
247
  return { type, context: null, reason, details: null };
258
248
  }
259
249
  /**
260
- * Creates a client failure carrying no status, wrapping the cause and its message.
250
+ * A failure with status 0 and one problem of the given type, whose reason is
251
+ * the cause's message.
261
252
  * @param {string} type
262
253
  * @param {any} cause
263
- * @returns
254
+ * @returns {HttpClientError}
264
255
  */
265
256
  static of(type, cause) {
266
257
  const reason = String(cause?.message ?? cause ?? 'unknown failure');
267
258
  return new HttpClientError(reason, 0, [HttpClientError.problem(type, reason)], cause);
268
259
  }
269
260
  /**
270
- * Creates an HttpClientError from a Response.
271
- * @param {Response} response
272
- * @returns an HttpClientError
261
+ * The failure an error response stands for, keeping its status. An
262
+ * `application/failures+json` body is the problems list; an
263
+ * `application/problem+json` body contributes its `problems`, or one
264
+ * GENERIC_PROBLEM made of its title and detail. Any other body is one
265
+ * GENERIC_PROBLEM carrying the body text. A json body that does not decode,
266
+ * or decodes to the wrong shape, and a body that cannot be read at all,
267
+ * each become one GENERIC_PROBLEM saying so.
268
+ * @param {Response} response consumed
269
+ * @returns {Promise<HttpClientError>}
273
270
  */
274
271
  static async fromResponse(response) {
275
272
  switch (MediaType.parse(response.headers.get('Content-Type')).normalized) {
@@ -304,14 +301,10 @@ class HttpClientError extends Failure {
304
301
  }
305
302
  }
306
303
  }
307
- /** marks a body whose json() rejected, telling it apart from a body decoding to json null */
308
304
  static #unreadable = Symbol('unreadable body');
309
305
  /**
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
306
  * @param {Response} response
314
- * @param {string} [as] - what the body does not decode as
307
+ * @param {string} [as] what the body does not decode as
315
308
  */
316
309
  static #undecodable(response, as = 'as json') {
317
310
  const mediaType = MediaType.parse(response.headers.get('Content-Type')).normalized;
@@ -319,9 +312,6 @@ class HttpClientError extends Failure {
319
312
  return new HttpClientError(message, response.status, [HttpClientError.problem('GENERIC_PROBLEM', message)]);
320
313
  }
321
314
  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
315
  const text = await response.text().catch(() => null);
326
316
  const message =
327
317
  text === null
@@ -334,19 +324,14 @@ class HttpClientError extends Failure {
334
324
  const metaContent = (name) =>
335
325
  globalThis.document?.querySelector(`meta[name="${name}"]`)?.getAttribute('content') ?? undefined;
336
326
 
337
- /**
338
- * @implements {HttpInterceptor}
339
- */
340
327
  /**
341
328
  * Sends the csrf header named by the page's `_csrf_header` meta, with the token
342
329
  * from `_csrf`. Both are read per request, so metas replaced after the client
343
330
  * was built are honoured, and the header is sent to the page's own origin only.
331
+ * @implements {HttpInterceptor}
344
332
  */
345
333
  class CsrfTokenInterceptor {
346
334
  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
335
  if (url.origin !== (globalThis.window?.location?.origin ?? url.origin)) {
351
336
  return await chain.proceed(url, request);
352
337
  }
@@ -358,13 +343,12 @@ class CsrfTokenInterceptor {
358
343
  return await chain.proceed(url, request);
359
344
  }
360
345
  }
361
- /**
362
- * @implements {HttpInterceptor}
363
- */
364
346
  /**
365
347
  * Navigates to a login url when a response comes back 401. The promise it
366
348
  * returns never settles, so callers keep waiting while the page unloads
367
- * instead of showing a failure nobody will be present to read.
349
+ * instead of showing a failure; where the navigation is blocked, as by a
350
+ * beforeunload prompt, they stay pending until the page leaves.
351
+ * @implements {HttpInterceptor}
368
352
  */
369
353
  class RedirectOnUnauthorizedInterceptor {
370
354
  #redirectUri;
@@ -378,10 +362,6 @@ class RedirectOnUnauthorizedInterceptor {
378
362
  const response = await chain.proceed(url, request);
379
363
  if (response.status === 401) {
380
364
  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
365
  return new Promise(() => {});
386
366
  }
387
367
  return response;
@@ -397,38 +377,48 @@ class HttpClientBuilder {
397
377
  constructor() {
398
378
  this.#interceptors = [];
399
379
  }
380
+ /**
381
+ * Adds the CsrfTokenInterceptor.
382
+ * @returns {HttpClientBuilder} this builder
383
+ */
400
384
  withCsrfToken() {
401
385
  this.#interceptors.push(new CsrfTokenInterceptor());
402
386
  return this;
403
387
  }
388
+ /**
389
+ * Adds the RedirectOnUnauthorizedInterceptor.
390
+ * @param {string} redirectUri where a 401 navigates to
391
+ * @returns {HttpClientBuilder} this builder
392
+ */
404
393
  withRedirectOnUnauthorized(redirectUri) {
405
394
  this.#interceptors.push(new RedirectOnUnauthorizedInterceptor(redirectUri));
406
395
  return this;
407
396
  }
408
397
  /**
398
+ * Adds interceptors, run in the order given after the ones added before.
409
399
  * @param {...HttpInterceptor} interceptors
400
+ * @returns {HttpClientBuilder} this builder
410
401
  */
411
402
  withInterceptors(...interceptors) {
412
403
  this.#interceptors.push(...interceptors);
413
404
  return this;
414
405
  }
406
+ /** @returns {HttpClient} a client running the interceptors added so far */
415
407
  build() {
416
408
  return new HttpClient(this.#interceptors);
417
409
  }
418
410
  }
419
411
 
420
412
  /**
413
+ * The last interceptor in every chain, performing the request with fetch. A
414
+ * fetch rejection becomes an HttpClientError with a CONNECTION_PROBLEM.
421
415
  * @implements {HttpInterceptor}
422
416
  */
423
- /** The last interceptor in every chain: the one that performs the request. */
424
417
  class HttpCall {
425
418
  async intercept(url, request, chain) {
426
419
  try {
427
420
  return await fetch(url, request);
428
421
  } 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
422
  throw HttpClientError.of('CONNECTION_PROBLEM', ex);
433
423
  }
434
424
  }
@@ -439,19 +429,18 @@ class HttpInterceptorChain {
439
429
  #interceptors;
440
430
  #current;
441
431
  /**
442
- *
443
432
  * @param {HttpInterceptor[]} interceptors
444
- * @param {number} current
433
+ * @param {number} current the index of the interceptor `proceed` runs
445
434
  */
446
435
  constructor(interceptors, current) {
447
436
  this.#interceptors = interceptors;
448
437
  this.#current = current;
449
438
  }
450
439
  /**
451
- *
440
+ * Runs the next interceptor.
452
441
  * @param {URL} url
453
442
  * @param {RequestInit} request
454
- * @returns {Promise<Response>} the response
443
+ * @returns {Promise<Response>}
455
444
  */
456
445
  async proceed(url, request) {
457
446
  const interceptor = this.#interceptors[this.#current];
@@ -485,12 +474,14 @@ class HttpClient {
485
474
  this.#interceptors = interceptors || [];
486
475
  }
487
476
  /**
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
477
+ * Performs an HTTP exchange through the client's interceptors, then the
478
+ * given ones.
479
+ * @param {string} uri the request url, relative to the page's
480
+ * @param {RequestInit|undefined} options fetch options
481
+ * @param {HttpInterceptor[]|undefined} interceptors run for this exchange only
482
+ * @returns {Promise<Response>} the response, whatever its status; rejecting
483
+ * with an HttpClientError CONNECTION_PROBLEM when the transport fails, or
484
+ * with what an interceptor throws
494
485
  */
495
486
  async exchange(uri, options, interceptors) {
496
487
  const is = [...this.#interceptors, ...(interceptors || []), new HttpCall()];
@@ -500,58 +491,58 @@ class HttpClient {
500
491
  return await chain.proceed(url, request);
501
492
  }
502
493
  /**
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
494
+ * Starts a request with any method.
495
+ * @param {string} method
496
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
497
+ * @returns {HttpRequestBuilder}
507
498
  */
508
499
  request(method, uri) {
509
500
  return HttpRequestBuilder.create(this, method, uri);
510
501
  }
511
502
  /**
512
- * Creates a request builder.
513
- * @param {string} uri - the (possibly relative) request url
514
- * @returns {HttpRequestBuilder} the request builder
503
+ * Starts a GET request.
504
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
505
+ * @returns {HttpRequestBuilder}
515
506
  */
516
507
  get(uri) {
517
508
  return HttpRequestBuilder.create(this, 'GET', uri);
518
509
  }
519
510
  /**
520
- * Creates a request builder.
521
- * @param {string} uri - the (possibly relative) request url
522
- * @returns {HttpRequestBuilder} the request builder
511
+ * Starts a HEAD request.
512
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
513
+ * @returns {HttpRequestBuilder}
523
514
  */
524
515
  head(uri) {
525
516
  return HttpRequestBuilder.create(this, 'HEAD', uri);
526
517
  }
527
518
  /**
528
- * Creates a request builder.
529
- * @param {string} uri - the (possibly relative) request url
530
- * @returns {HttpRequestBuilder} the request builder
519
+ * Starts a POST request.
520
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
521
+ * @returns {HttpRequestBuilder}
531
522
  */
532
523
  post(uri) {
533
524
  return HttpRequestBuilder.create(this, 'POST', uri);
534
525
  }
535
526
  /**
536
- * Creates a request builder.
537
- * @param {string} uri - the (possibly relative) request url
538
- * @returns {HttpRequestBuilder} the request builder
527
+ * Starts a PUT request.
528
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
529
+ * @returns {HttpRequestBuilder}
539
530
  */
540
531
  put(uri) {
541
532
  return HttpRequestBuilder.create(this, 'PUT', uri);
542
533
  }
543
534
  /**
544
- * Creates a request builder.
545
- * @param {string} uri - the (possibly relative) request url
546
- * @returns {HttpRequestBuilder} the request builder
535
+ * Starts a PATCH request.
536
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
537
+ * @returns {HttpRequestBuilder}
547
538
  */
548
539
  patch(uri) {
549
540
  return HttpRequestBuilder.create(this, 'PATCH', uri);
550
541
  }
551
542
  /**
552
- * Creates a request builder.
553
- * @param {string} uri - the (possibly relative) request url
554
- * @returns {HttpRequestBuilder} the request builder
543
+ * Starts a DELETE request.
544
+ * @param {string} uri the request url, relative to the page's; a query and a fragment in it are kept
545
+ * @returns {HttpRequestBuilder}
555
546
  */
556
547
  delete(uri) {
557
548
  return HttpRequestBuilder.create(this, 'DELETE', uri);
@@ -562,7 +553,7 @@ class HttpClient {
562
553
  * Reads the response body as the given type, wrapping a failed read as an UNMARSHALING_PROBLEM.
563
554
  * @param {Response} response
564
555
  * @param {'text'|'json'|'blob'|'arrayBuffer'} type
565
- * @returns
556
+ * @returns {Promise<any>}
566
557
  */
567
558
  const unmarshal = async (response, type) => {
568
559
  try {
@@ -573,9 +564,7 @@ const unmarshal = async (response, type) => {
573
564
  };
574
565
 
575
566
  /**
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.
567
+ * The entries of a headers or params initializer with nullish values kept as they are.
579
568
  * @param {any} source
580
569
  * @returns {Iterable<[string, any]>}
581
570
  */
@@ -609,15 +598,14 @@ class HttpRequestBuilder {
609
598
  #interceptors;
610
599
  #fragment;
611
600
  /**
612
- * Creates an HttpRequestBuilder.
601
+ * A builder for the url: its query becomes the initial params, split at the
602
+ * first `?`, and its fragment, from the first `#`, is kept for the final url.
613
603
  * @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
604
+ * @param {string} method
605
+ * @param {string} uri
606
+ * @returns {HttpRequestBuilder}
617
607
  */
618
608
  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
609
  const hashIndex = uri.indexOf('#');
622
610
  const fragment = hashIndex === -1 ? '' : uri.slice(hashIndex);
623
611
  const withoutFragment = hashIndex === -1 ? uri : uri.slice(0, hashIndex);
@@ -635,10 +623,9 @@ class HttpRequestBuilder {
635
623
  );
636
624
  }
637
625
  /**
638
- * Creates an HttpRequestBuilder.
639
626
  * @param {HttpClient} client
640
- * @param {string} method - the HTTP method to be used
641
- * @param {string} uri - the (possibly relative) request url
627
+ * @param {string} method
628
+ * @param {string} uri the url without query and fragment
642
629
  * @param {URLSearchParams} params
643
630
  * @param {Headers} headers
644
631
  * @param {any} body
@@ -658,24 +645,20 @@ class HttpRequestBuilder {
658
645
  this.#fragment = fragment;
659
646
  }
660
647
  /**
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.
648
+ * Sets each header, replacing a header of the same name; a null or undefined value removes it.
662
649
  * @param {HeadersInit|Record<string,string|null|undefined>} hs
663
650
  * @returns {HttpRequestBuilder} this builder
664
651
  */
665
652
  headers(hs) {
666
653
  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
- }
654
+ this.header(k, v);
672
655
  }
673
656
  return this;
674
657
  }
675
658
  /**
676
- * Adds an header to the request, overriding it if it already exists. Null and undefined values cause the key to be removed
659
+ * Sets a header, replacing one of the same name; a null or undefined value removes it.
677
660
  * @param {string} k
678
- * @param {string} v
661
+ * @param {string|null|undefined} v
679
662
  * @returns {HttpRequestBuilder} this builder
680
663
  */
681
664
  header(k, v) {
@@ -687,7 +670,7 @@ class HttpRequestBuilder {
687
670
  return this;
688
671
  }
689
672
  /**
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
673
+ * Sets each query parameter in place, replacing a parameter of the same name; a null or undefined value removes it.
691
674
  * @param {URLSearchParams|Record<string,string|null|undefined>|string[][]|string} ps
692
675
  * @returns {HttpRequestBuilder} this builder
693
676
  */
@@ -702,20 +685,14 @@ class HttpRequestBuilder {
702
685
  return this;
703
686
  }
704
687
  /**
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.
688
+ * 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
689
  * @param {string} k
707
690
  * @param {...string} vs
708
691
  * @returns {HttpRequestBuilder} this builder
709
692
  */
710
693
  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
694
  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) {
695
+ for (const v of vs.filter((v) => v != null)) {
719
696
  this.#params.append(k, v);
720
697
  }
721
698
  return this;
@@ -750,6 +727,7 @@ class HttpRequestBuilder {
750
727
  * Sets the request body as a FormData configured using the callback.
751
728
  * `Content-Type: multipart/form-data` header is automatically added by fetch if not explicitly set.
752
729
  * @param {(c: HttpMultipartRequestCustomizer) => void} callback
730
+ * @returns {HttpRequestBuilder} this builder
753
731
  */
754
732
  multipart(callback) {
755
733
  const formData = new FormData();
@@ -759,7 +737,7 @@ class HttpRequestBuilder {
759
737
  return this;
760
738
  }
761
739
  /**
762
- * Sets a fetch options for the request.
740
+ * Merges fetch options into the request's.
763
741
  * @param {Omit<RequestInit,"headers"|"method"|"body">} kvs
764
742
  * @returns {HttpRequestBuilder} this builder
765
743
  */
@@ -780,8 +758,8 @@ class HttpRequestBuilder {
780
758
  return this;
781
759
  }
782
760
  /**
783
- * Adds interceptors to the request.
784
- * @param {[HttpInterceptor]} is - the interceptor to be registered
761
+ * Adds interceptors run for this request only, after the client's.
762
+ * @param {HttpInterceptor[]} is
785
763
  * @returns {HttpRequestBuilder} this builder
786
764
  */
787
765
  interceptors(is) {
@@ -791,8 +769,8 @@ class HttpRequestBuilder {
791
769
  return this;
792
770
  }
793
771
  /**
794
- * Adds an interceptor to the request.
795
- * @param {HttpInterceptor} i - the interceptor to be registered
772
+ * Adds one interceptor run for this request only, after the client's.
773
+ * @param {HttpInterceptor} i
796
774
  * @returns {HttpRequestBuilder} this builder
797
775
  */
798
776
  interceptor(i) {
@@ -800,8 +778,8 @@ class HttpRequestBuilder {
800
778
  return this;
801
779
  }
802
780
  /**
803
- * Performs an HTTP exchange using the configured client, request and interceptors.
804
- * @returns {Promise<Response>} the response
781
+ * Sends the request and answers the Response, whatever its status.
782
+ * @returns {Promise<Response>} rejecting as `HttpClient.exchange` does
805
783
  */
806
784
  async exchange() {
807
785
  const query = this.#params.size ? `?${this.#params}` : '';
@@ -814,8 +792,12 @@ class HttpRequestBuilder {
814
792
  return await this.#client.exchange(`${this.#uri}${query}${this.#fragment}`, opts, this.#interceptors);
815
793
  }
816
794
  /**
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
795
+ * Sends the request and answers the Response when its status is 200-299.
796
+ * Always rejects with an HttpClientError: `HttpClientError.fromResponse`
797
+ * for an error status, CONNECTION_PROBLEM for a transport failure, and
798
+ * UNEXPECTED_PROBLEM, carrying the error as its cause, for anything an
799
+ * interceptor throws that is not already a Failure.
800
+ * @returns {Promise<Response>}
819
801
  */
820
802
  async fetch() {
821
803
  try {
@@ -828,45 +810,40 @@ class HttpRequestBuilder {
828
810
  if (ex instanceof Failure) {
829
811
  throw ex;
830
812
  }
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
813
  throw HttpClientError.of('UNEXPECTED_PROBLEM', ex);
836
814
  }
837
815
  }
838
816
  /**
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
817
+ * `fetch()`, then the body as text; a body that cannot be read rejects with UNMARSHALING_PROBLEM.
818
+ * @returns {Promise<string>}
841
819
  */
842
820
  async fetchText() {
843
821
  const response = await this.fetch();
844
822
  return await unmarshal(response, 'text');
845
823
  }
846
824
  /**
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
825
+ * `fetch()`, then the body parsed as json. A 204 yields null without reading
826
+ * the body; any other body that does not parse rejects with UNMARSHALING_PROBLEM.
827
+ * @returns {Promise<any>}
850
828
  */
851
829
  async fetchJson() {
852
830
  const response = await this.fetch();
853
831
  if (response.status === 204) {
854
- //a 204 declares no content: there is no body to decode
855
832
  return null;
856
833
  }
857
834
  return await unmarshal(response, 'json');
858
835
  }
859
836
  /**
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
837
+ * `fetch()`, then the body as a Blob; a body that cannot be read rejects with UNMARSHALING_PROBLEM.
838
+ * @returns {Promise<Blob>}
862
839
  */
863
840
  async fetchBlob() {
864
841
  const response = await this.fetch();
865
842
  return await unmarshal(response, 'blob');
866
843
  }
867
844
  /**
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
845
+ * `fetch()`, then the body as an ArrayBuffer; a body that cannot be read rejects with UNMARSHALING_PROBLEM.
846
+ * @returns {Promise<ArrayBuffer>}
870
847
  */
871
848
  async fetchArrayBuffer() {
872
849
  const response = await this.fetch();
@@ -877,10 +854,7 @@ class HttpRequestBuilder {
877
854
  /** Builds a multipart body: text fields, json parts, and single or repeated blobs. */
878
855
  class HttpMultipartRequestCustomizer {
879
856
  #formData;
880
- /**
881
- *
882
- * @param {FormData} formData
883
- */
857
+ /** @param {FormData} formData the body being built */
884
858
  constructor(formData) {
885
859
  this.#formData = formData;
886
860
  }
@@ -888,33 +862,30 @@ class HttpMultipartRequestCustomizer {
888
862
  * Appends a value to the FormData.
889
863
  * @param {string} name
890
864
  * @param {*} value
891
- * @returns this builder
865
+ * @returns {HttpMultipartRequestCustomizer} this builder
892
866
  */
893
867
  field(name, value) {
894
868
  this.#formData.append(name, value);
895
869
  return this;
896
870
  }
897
871
  /**
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.
872
+ * Appends a Blob to the FormData. Without a filename a File keeps its own
873
+ * and any other Blob is named "blob".
902
874
  * @param {string} name
903
875
  * @param {Blob} value
904
- * @param {string|undefined} filename
905
- * @returns this builder
876
+ * @param {string} [filename]
877
+ * @returns {HttpMultipartRequestCustomizer} this builder
906
878
  */
907
879
  blob(name, value, filename) {
908
880
  this.#formData.append(name, value, filename);
909
881
  return this;
910
882
  }
911
883
  /**
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.
884
+ * Appends each Blob under the same name, a File keeping its own filename and
885
+ * any other Blob named "blob".
915
886
  * @param {string} name
916
887
  * @param {Blob[]} values
917
- * @returns this builder
888
+ * @returns {HttpMultipartRequestCustomizer} this builder
918
889
  */
919
890
  blobs(name, values) {
920
891
  for (const v of values) {
@@ -923,11 +894,11 @@ class HttpMultipartRequestCustomizer {
923
894
  return this;
924
895
  }
925
896
  /**
926
- * Appends a JSON serialized blob to the FormData.
897
+ * Appends the value serialized as an `application/json` blob.
927
898
  * @param {string} name
928
899
  * @param {any} value
929
- * @param {string|undefined} filename
930
- * @returns this builder
900
+ * @param {string} [filename]
901
+ * @returns {HttpMultipartRequestCustomizer} this builder
931
902
  */
932
903
  json(name, value, filename) {
933
904
  const blob = new Blob([JSON.stringify(value)], { type: 'application/json' });