@optionfactory/fml 9.0.0-rc3 → 9.0.0-rc30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -88
- package/dist/client-errors.iife.js +40 -49
- package/dist/client-errors.iife.js.map +1 -1
- package/dist/client-errors.iife.min.js +1 -1
- package/dist/client-errors.iife.min.js.map +1 -1
- package/dist/custom-elements.json +483 -50
- package/dist/fml.css +25 -20
- package/dist/fml.css.map +1 -1
- package/dist/fml.iife.js +5688 -2542
- package/dist/fml.iife.js.map +1 -1
- package/dist/fml.iife.min.js +1 -1
- package/dist/fml.iife.min.js.map +1 -1
- package/dist/ftl.d.mts +398 -227
- package/dist/ftl.iife.js +682 -1016
- package/dist/ftl.iife.js.map +1 -1
- package/dist/ftl.iife.min.js +1 -1
- package/dist/ftl.iife.min.js.map +1 -1
- package/dist/ftl.min.mjs +1 -1
- package/dist/ftl.min.mjs.map +1 -1
- package/dist/ftl.mjs +682 -1016
- package/dist/ftl.mjs.map +1 -1
- package/dist/ful.css +25 -20
- package/dist/ful.css.map +1 -1
- package/dist/ful.d.mts +3043 -546
- package/dist/ful.iife.js +4875 -1387
- package/dist/ful.iife.js.map +1 -1
- package/dist/ful.iife.min.js +1 -1
- package/dist/ful.iife.min.js.map +1 -1
- package/dist/ful.min.mjs +1 -1
- package/dist/ful.min.mjs.map +1 -1
- package/dist/ful.mjs +4873 -1389
- package/dist/ful.mjs.map +1 -1
- package/dist/httpc.d.mts +143 -123
- package/dist/httpc.iife.js +163 -175
- package/dist/httpc.iife.js.map +1 -1
- package/dist/httpc.iife.min.js +1 -1
- package/dist/httpc.iife.min.js.map +1 -1
- package/dist/httpc.min.mjs +1 -1
- package/dist/httpc.min.mjs.map +1 -1
- package/dist/httpc.mjs +163 -175
- package/dist/httpc.mjs.map +1 -1
- package/dist/vscode.html-custom-data.json +234 -38
- package/dist/web-types.json +489 -50
- 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
|
|
169
|
-
*
|
|
170
|
-
* @
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
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
|
|
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
|
|
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} [
|
|
324
|
+
* @param {string} [flaw] what is wrong with the body
|
|
315
325
|
*/
|
|
316
|
-
static #undecodable(response,
|
|
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
|
|
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
|
|
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>}
|
|
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
|
-
*
|
|
490
|
-
* @param {string} uri
|
|
491
|
-
* @param {RequestInit|undefined} options
|
|
492
|
-
* @param {HttpInterceptor[]|undefined} interceptors
|
|
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
|
-
*
|
|
504
|
-
* @param {string} method
|
|
505
|
-
* @param {string} uri
|
|
506
|
-
* @returns {HttpRequestBuilder}
|
|
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
|
-
*
|
|
513
|
-
* @param {string} uri
|
|
514
|
-
* @returns {HttpRequestBuilder}
|
|
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
|
-
*
|
|
521
|
-
* @param {string} uri
|
|
522
|
-
* @returns {HttpRequestBuilder}
|
|
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
|
-
*
|
|
529
|
-
* @param {string} uri
|
|
530
|
-
* @returns {HttpRequestBuilder}
|
|
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
|
-
*
|
|
537
|
-
* @param {string} uri
|
|
538
|
-
* @returns {HttpRequestBuilder}
|
|
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
|
-
*
|
|
545
|
-
* @param {string} uri
|
|
546
|
-
* @returns {HttpRequestBuilder}
|
|
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
|
-
*
|
|
553
|
-
* @param {string} uri
|
|
554
|
-
* @returns {HttpRequestBuilder}
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
615
|
-
* @param {string} uri
|
|
616
|
-
* @returns {HttpRequestBuilder}
|
|
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
|
|
641
|
-
* @param {string} uri
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
784
|
-
* @param {[
|
|
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
|
|
795
|
-
* @param {HttpInterceptor} i
|
|
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
|
-
*
|
|
804
|
-
* @returns {Promise<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
|
-
*
|
|
818
|
-
*
|
|
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
|
-
*
|
|
840
|
-
* @returns {Promise<string>}
|
|
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
|
-
*
|
|
848
|
-
*
|
|
849
|
-
* @returns {Promise<any>}
|
|
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
|
-
*
|
|
861
|
-
* @returns {Promise<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
|
-
*
|
|
869
|
-
* @returns {Promise<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
|
-
*
|
|
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
|
|
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
|
|
913
|
-
*
|
|
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
|
|
914
|
+
* Appends the value serialized as an `application/json` blob.
|
|
927
915
|
* @param {string} name
|
|
928
916
|
* @param {any} value
|
|
929
|
-
* @param {string
|
|
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' });
|