@webqit/fetch-plus 0.1.2 → 0.1.4

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 CHANGED
@@ -1,3 +1,2051 @@
1
- # Fetch+
1
+ # Fetch+ – _Advanced HTTP for the Modern Web_
2
2
 
3
- Upgraded fetch API and the LiveResponse API. Docs coming soon.
3
+ [![npm version][npm-version-src]][npm-version-href]
4
+ [![bundle][bundle-src]][bundle-href]
5
+ [![License][license-src]][npm-version-href]
6
+
7
+ Fetch+ extends the web’s request/response model and its core primitives to support more ambitious application development.
8
+ Fetch+ introduces:
9
+
10
+ 1. **A LiveResponse API** – a new response primitive that makes realtime communication native to the existing request/response model.
11
+ 2. **Design extensions to the Fetch API** – a set of additions to `fetch`, `Request`, `Response`, `Headers`, and `FormData` that addresses the unintuitive parts of the API.
12
+
13
+ These represent two distinct capability families but one coherent upgrade to the transport layer and its interfaces.
14
+
15
+ This README is divided accordingly into two sections:
16
+
17
+ 1. [`LiveResponse`](#section-1-liveresponse)
18
+ 2. [Fetch API Extensions](#section-2-fetch-api-extensions)
19
+
20
+ > [!NOTE]
21
+ >
22
+ > The documentation is expansive by design.
23
+ > The code doing the work is not — Fetch+ weighs < `7 KiB min | gzip`.
24
+
25
+ ---
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ npm i @webqit/fetch-plus
31
+ ```
32
+
33
+ ```js
34
+ import { LiveResponse, RequestPlus, ResponsePlus, HeadersPlus, FormDataPlus, fetchPlus, Observer } from '@webqit/fetch-plus';
35
+ ```
36
+
37
+ ## CDN Include
38
+
39
+ ```html
40
+ <script src="https://unpkg.com/@webqit/fetch-plus/dist/main.js"></script>
41
+
42
+ <script>
43
+ const { LiveResponse, RequestPlus, ResponsePlus, HeadersPlus, FormDataPlus, fetchPlus, Observer } = window.webqit;
44
+ </script>
45
+ ```
46
+
47
+ ---
48
+
49
+ ## _Section 1_: `LiveResponse`
50
+
51
+ Applications increasingly need to work in real time across network boundaries. Traditionally, this has required a split architecture:
52
+
53
+ + an initial HTTP request/response path, paired with
54
+ + a separate, long-lived update path, typically backed by web sockets
55
+
56
+ coordinated at the application level.
57
+
58
+ Fetch+ removes the need for this split by extending the request/response model with "live" responses. LiveResponse allows application state, transitions, and messaging to be expressed as properties of the existing request/response model.
59
+
60
+ A `LiveResponse` is a "live" representation of application-level data – an object, an array, a string, a number, etc. – that crosses the wire *by reference*.
61
+
62
+ ```js
63
+ // On the server
64
+ const state = { count: 0 };
65
+ const response = new LiveResponse(state);
66
+ return response;
67
+ ```
68
+
69
+ The client gains the response as a reference to the original server-side instance.
70
+
71
+ ```js
72
+ // On the client
73
+ const response = new LiveResponse(await fetch('http://localhost/counter'));
74
+ const state = (await response.now()).body;
75
+ console.log(state); // { count: 0 }
76
+ ```
77
+
78
+ What makes this "live response" is the live relationship and interactivity between the client-side instance and the server-side instance.
79
+
80
+ LiveResponse works in real-time in three ways:
81
+
82
+ 1. Supports live state projection via mutable response bodies.
83
+ 2. Offers a multi-response architecture via response swaps.
84
+ 3. Supports bidirectional messaging via message ports.
85
+
86
+ ### 1. Live State Projection via Mutable Response Bodies
87
+
88
+ Being a live reference across the wire, when the body of a `LiveResponse` is a mutable value, mutations applied on the server are reflected on the client.
89
+
90
+ ```js
91
+ // On the server
92
+ Observer.set(state, 'count', value);
93
+
94
+ // On the client
95
+ console.log(state.count); // value
96
+ ```
97
+
98
+ Concretely, this looks like this:
99
+
100
+ **On the server:**
101
+
102
+ ```js
103
+ const state = { count: 0 };
104
+ const response = new LiveResponse(state);
105
+
106
+ setInterval(() => {
107
+ Observer.set(state, 'count', state.count + 1);
108
+ }, 1000);
109
+
110
+ return response;
111
+ ```
112
+
113
+ **On the client:**
114
+
115
+ ```js
116
+ const response = new LiveResponse(await fetch('http://localhost/counter'));
117
+ const state = (await response.now()).body;
118
+
119
+ Observer.observe(state, () => {
120
+ console.log(state.count);
121
+ });
122
+ ```
123
+
124
+ ### 2. A Multi-Response Architecture via Response Swaps
125
+
126
+ Over the same instance, a live response may model multiple responses across time. They're designed to be replaced in-place.
127
+
128
+ Replacements are entire response swaps — status, headers, and body — to a new response.
129
+
130
+ ```js
131
+ // On the server
132
+ res.replaceWith(newState, { status, statusText, headers, done });
133
+
134
+ // On the client
135
+ console.log(response.body); // newState
136
+ state = response.body;
137
+ ```
138
+
139
+ Concretely, this looks like this:
140
+
141
+ **On the server:**
142
+
143
+ ```js
144
+ const response = new LiveResponse({ pageTitle: 'Hello World' }, { done: false });
145
+
146
+ setTimeout(() => {
147
+ response.replaceWith({ pageTitle: 'Hello again World' }, { done: false });
148
+ }, 2000);
149
+
150
+ setTimeout(() => {
151
+ response.replaceWith(null, { status: 302, headers: { Location: '/' }, done: true });
152
+ }, 4000);
153
+
154
+ return response;
155
+ ```
156
+
157
+ **On the client:**
158
+
159
+ ```js
160
+ const response = new LiveResponse(await fetch('http://localhost/hello'));
161
+ console.log((await response.now()).body); // { pageTitle: 'Hello World' }
162
+
163
+ response.addEventListener('replace', () => {
164
+ if (response.headers.get('Location')) {
165
+ handleRedirect(response.headers.get('Location'));
166
+ return;
167
+ }
168
+ console.log(response.body); // { pageTitle: 'Hello again World' }
169
+ state = response.body;
170
+ });
171
+ ```
172
+
173
+ ### 3. Bidirectional Messaging via Message Ports
174
+
175
+ Live responses are backed by real-time message ports that by themselves enable bidirectional messaging.
176
+ The server holds one end of the port, while the client – the client-side LiveResponse instance – holds the other.
177
+
178
+ ```js
179
+ // On the server
180
+ request.port.postMessage('Hello from server');
181
+ request.port.addEventListener('message', (event) => {
182
+ console.log(event.data); // Hello from client
183
+ });
184
+
185
+ // On the client
186
+ response.port.postMessage('Hello from client');
187
+ response.port.addEventListener('message', (event) => {
188
+ console.log(event.data); // Hello from server
189
+ });
190
+ ```
191
+
192
+ Concretely, this looks like this:
193
+
194
+ **On the server:**
195
+
196
+ ```js
197
+ async function handle(request, signal, done) {
198
+ // Assuming that the application runtime injects "request.port", "signal", and "done"
199
+ // and manages the relevant lifecycles
200
+
201
+ request.port.postMessage('Hello from server');
202
+ request.port.addEventListener('message', (event) => {
203
+ console.log(event.data); // Hello from client
204
+ }, { signal });
205
+
206
+ // ---- other logic ----
207
+
208
+ const response = new LiveResponse({ pageTitle: 'Hello World' });
209
+
210
+ setTimeout(() => {
211
+ if (!signal.aborted) {
212
+ response.replaceWith({ pageTitle: 'Hello again World' }, { done: false });
213
+ }
214
+ done();
215
+ }, 5000);
216
+
217
+ // Assuming that the application runtime accepts LiveResponse as return value
218
+ // and maps it back to the output stream
219
+ return response;
220
+ }
221
+ ```
222
+
223
+ Note that `request.port` above is assumed to be injected by the application runtime. Its creation is shown soon in the Sample Express App area.
224
+
225
+ > [!TIP]
226
+ >
227
+ > Note the distinction between `request.port` – as used above – and `response.port`.
228
+ > While `request.port` refers to a port instantiated by the application runtime per request (which the client is expected to connect to),
229
+ > `response.port` is a port instantiated by `LiveResponse` per the response of that request. Think of it as:
230
+ >
231
+ > ```js
232
+ > (client) response.port ◀────▶ request.port (server)
233
+ > ```
234
+
235
+ **On the client:**
236
+
237
+ ```js
238
+ const response = new LiveResponse(await fetch('http://localhost/hello'));
239
+
240
+ response.port.postMessage('Hello from client');
241
+ response.port.addEventListener('message', (event) => {
242
+ console.log(event.data); // Hello from server
243
+ });
244
+ ```
245
+
246
+ ### Backend Integration
247
+
248
+ `LiveResponse`-based backends are easy to build. This typically involves:
249
+
250
+ 1. Creating the server-side port and exposing it – as `request.port` for example
251
+ 2. Managing request + port lifecycles via abort signals
252
+ 3. Converting `LiveResponse` to a standard response
253
+ 4. Adding the `X-Message-Port` header to the outgoing response
254
+
255
+ See the sample Express.js integration below for a complete example.
256
+
257
+ For a framework with a live-mode-first architecture, see [Webflo](https://github.com/webqit/webflo).
258
+
259
+ #### Sample Express.js Integration
260
+
261
+ The following is a sample `LiveResponse` integration with Express.js.
262
+
263
+ As a high-level overview:
264
+
265
+ 1. `/hello` is an interactive route that uses `LiveResponse` and `request.port`.
266
+ 2. The core of the integration is in the `interactiveRoute` function below.
267
+ 3. The web socket integration is provided by `express-ws`.
268
+ 4. `StarPort` and `WebSocketPort` are `LiveResponse`-native port interfaces.
269
+
270
+ ```js
271
+ // ----- the setup -----
272
+ import express from 'express';
273
+ import expressWs from 'express-ws';
274
+ import { StarPort, WebSocketPort } from '@webqit/port-plus';
275
+ import { LiveResponse } from '@webqit/fetch-plus';
276
+
277
+ const app = express();
278
+ expressWs(app);
279
+
280
+ app.listen(3000);
281
+ ```
282
+
283
+ ```js
284
+ // ----- route handling -----
285
+ app.get('/hello', (req, res) => {
286
+ interactiveRoute(req, res, async (req, signal, done) => {
287
+ // "request.port" is injected by now
288
+
289
+ req.port.postMessage('Hello from server');
290
+ req.port.addEventListener('message', (event) => {
291
+ console.log(event.data); // Hello from client
292
+ }, { signal });
293
+
294
+ const response = new LiveResponse({ pageTitle: 'Hello World' });
295
+
296
+ setTimeout(() => {
297
+ if (!signal.aborted) {
298
+ response.replaceWith({ pageTitle: 'Hello again World' }, { done: false });
299
+ }
300
+ done();
301
+ }, 5000);
302
+
303
+ // LiveResponse as return value
304
+ return response;
305
+ });
306
+ });
307
+ ```
308
+
309
+ ```js
310
+ // ----- the integration -----
311
+ const portRegistry = new Map();
312
+ app.ws('/', function(ws, req) {
313
+ const url = new URL(req.url, `http://${req.headers.host}`);
314
+ if (!url.searchParams.has('port_id')) {
315
+ ws.close();
316
+ return;
317
+ }
318
+ const portId = url.searchParams.get('port_id');
319
+ const wsPort = new WebSocketPort(ws);
320
+ // All connecting clients over portId go into the same star port
321
+ portRegistry.get(portId).addPort(wsPort);
322
+ });
323
+
324
+ async function interactiveRoute(req, res, handle) {
325
+ // --- before handle ---
326
+ req.port = new StarPort();
327
+ const portId = crypto.randomUUID();
328
+ portRegistry.set(portId, req.port);
329
+
330
+ const abortController = new AbortController();
331
+ const doneCallback = () => {
332
+ abortController.abort();
333
+ req.port.close();
334
+ portRegistry.delete(portId);
335
+ };
336
+ // ---
337
+
338
+ const response = await handle(req, abortController.signal, doneCallback);
339
+
340
+ // --- after handle ---
341
+ // Convert the response to a WHATWG response
342
+ const outgoingRes = response.toResponse({ port: req.port, signal: abortController.signal });
343
+
344
+ // Add the realtime port header – tells the client where to connect to.
345
+ // On the client-side, LiveResponse detects the header and connects to the web socket URL.
346
+ outgoingRes.headers.set('X-Message-Port', `socket:///?port_id=${portId}`);
347
+ // MADE OF TWO PARTS:
348
+ // 1. The port scheme "socket://" (as defined by LiveResponse)
349
+ // 2. The connection URI "/?port_id=portId" (as defined by the server). You almost always want this part to begin with a slash.
350
+
351
+ // Pipe the response to the nodejs response stream
352
+ for (const [name, value] of outgoingRes.headers) {
353
+ res.setHeader(name, value);
354
+ }
355
+ outgoingRes.body.pipeTo(res);
356
+ // ---
357
+
358
+ // LIFECYCLE TIP:
359
+ // 1. At this point, the port remains interactive until handler calls our doneCallback above
360
+ // 2. But we can also shortcut the process by calling doneCallback() above based on some condition
361
+ //if (condition) {
362
+ // doneCallback();
363
+ //}
364
+ }
365
+ ```
366
+
367
+ ### Implementation Guide
368
+
369
+ #### Ports & Channels
370
+
371
+ Live responses are backed by real-time message ports.
372
+ The server holds one end of the port, while the client – the client-side LiveResponse instance – holds the other.
373
+ LiveResponse communicates over the established channel.
374
+
375
+ Ports in LiveResponse are based on [Port+](https://github.com/webqit/port-plus). It makes it possible
376
+ for LiveResponse to work universally against the same port interface; multiple messaging primitives, same port interface:
377
+
378
+ + WebSocket – via `WebSocketPort`
379
+ + BroadcastChannel – via `BroadcastChannelPlus`
380
+ + MessageChannel – via `MessageChannelPlus`
381
+
382
+ LiveResponse can therefore be used between:
383
+
384
+ + Server ◀────▶ Client – backed by WebSocket
385
+ + Service Worker ◀────▶ Main Thread – backed by BroadcastChannel
386
+ + Main Thread ◀────▶ Main Thread – backed by BroadcastChannel or MessageChannel
387
+
388
+ ##### Server ◀────▶ Client
389
+
390
+ The idea here is to create a port instance on the server for the given request
391
+ and "invite" the issuing client to connect to it. To achieve this, the port instance is
392
+ assigned a unique identifier. That identifier is sent in the invite.
393
+ This is done via the `X-Message-Port` header.
394
+
395
+ ```js
396
+ import { StarPort, WebSocketPort } from '@webqit/port-plus';
397
+
398
+ // Create a port that will contain the ws instance
399
+ req.port = new StarPort();
400
+ const portId = crypto.randomUUID();
401
+ portRegistry.set(portId, req.port);
402
+ ```
403
+
404
+ > [!TIP]
405
+ >
406
+ > `StarPort` is a "super" port that proxies other ports; more aptly, a "star topology" port.
407
+ > In this scenario, It lets us have a reference port instance even before the client connects over WebSocket.
408
+ > Messages sent ahead of that implicitly wait. The first connecting client sees them.
409
+
410
+ ```js
411
+ // When the client connects...
412
+ const wsPort = new WebSocketPort(ws);
413
+ // use the port ID from the request URL
414
+ // to identify the original port it belongs. Add it
415
+ portRegistry.get(portId).addPort(wsPort);
416
+ ```
417
+
418
+ ```js
419
+ // Convert the LiveResponse to a standard Response
420
+ const outgoingRes = response.toResponse({ port: req.port, signal: abortController.signal });
421
+
422
+ // Attach the X-Message-Port header and send
423
+ outgoingRes.headers.set('X-Message-Port', `socket:///?port_id=${portId}`);
424
+ send(outgoingRes);
425
+ ```
426
+
427
+ On the client, LiveResponse detects the presence of this header, and the port scheme, and connects via WebSocket.
428
+
429
+ ```js
430
+ const serverResponse = await fetch('http://localhost/hello');
431
+ const response = new LiveResponse(serverResponse);
432
+ ```
433
+
434
+ The resulting `response.port` interface on the client is `WebSocketPort`. It is the same interface as the rest, just backed by WebSocket.
435
+
436
+ ##### Service Worker ◀────▶ Main Thread
437
+
438
+ The idea here is similar to the previous, but with a different port primitive, and a different port scheme.
439
+
440
+ ```js
441
+ import { BroadcastChannelPlus } from '@webqit/port-plus';
442
+
443
+ // Create a Broadcast Channel that the client will connect to
444
+ // Mark it as the "server" port
445
+ const portId = crypto.randomUUID();
446
+ const req.port = new BroadcastChannelPlus(portId, {
447
+ clientServerMode: 'server',
448
+ postAwaitsOpen: true,
449
+ autoStart: true // Ensure it's ready to accept connections
450
+ });
451
+ ```
452
+
453
+ ```js
454
+ // Convert the LiveResponse to a standard Response
455
+ const outgoingRes = response.toResponse({ port: req.port, signal: abortController.signal });
456
+
457
+ // Attach the X-Message-Port header and send
458
+ outgoingRes.headers.set('X-Message-Port', `channel://${portId}`);
459
+ send(outgoingRes);
460
+ ```
461
+
462
+ On the client, LiveResponse detects the presence of this header, and the port scheme, and connects via Broadcast Channel.
463
+
464
+ ```js
465
+ const swResponse = await fetch('http://localhost/hello');
466
+ const response = new LiveResponse(swResponse);
467
+ ```
468
+
469
+ The resulting `response.port` interface on the client is `BroadcastChannelPlus`. It is the same interface as the rest, but based on the `BroadcastChannel` API.
470
+
471
+ ##### Main Thread ◀────▶ Main Thread
472
+
473
+ For Single Page Applications that handle navigations with a request/response model right in the browser UI,
474
+ it may be desired to support the LiveResponse model – and that is possible. Since there is no concept of the network layer, no encoding and decoding
475
+ between LiveResponse and standard Response is required; just direct passing of a LiveResponse instance.
476
+
477
+ The port model for this scenario is MessageChannel. The request handler creates an instance and holds on to `port1` or `port2`, and
478
+ directly injects the other into the LiveResponse instance:
479
+
480
+ ```js
481
+ import { MessageChannelPlus } from '@webqit/port-plus';
482
+
483
+ async function handle(req) {
484
+ // Create and assign the port
485
+ const messageChannel = new MessageChannelPlus;
486
+ req.port = messageChannel.port1;
487
+
488
+ // ----- Handle the request -----
489
+ const data = await getData();
490
+ const response = new LiveResponse(data);
491
+
492
+ // Inject the other port into LiveResponse
493
+ LiveResponse.attachPort(response, messageChannel.port2);
494
+ return response;
495
+ }
496
+ ```
497
+
498
+ Both `port1` and `port2` in this scenario are `MessagePortPlus` interfaces. They are, again, the same interface as the rest, but based on the `MessageChannel` API.
499
+
500
+ ##### The `X-Message-Port` Header
501
+
502
+ The `X-Message-Port` header has a specific format that is made of two parts:
503
+
504
+ 1. The port scheme – as defined by LiveResponse. This is strictly either `"socket://"` (for WebSocket-backed ports), or `"channel://"` (for BroadcastChannel-backed ports).
505
+ 2. The connection URI or channel name – as defined by the application. This must be unique to the request being processed. This may look like `/?port_id=<portId>` (for a WebSocket connection URI; and you almost always want this part to begin with a slash), or `<channelName>` (for a BroadcastChannel).
506
+
507
+ Together, that typically looks like: `"socket:///?port_id=smkdnjdnjd67734n"` | `"channel://smkdnjdnjd67734n"`.
508
+
509
+ #### Mutations
510
+
511
+ Mutability is a foundational concept in LiveResponse. It gives the mental model of a stable object reference across time, with the potential
512
+ to change. This concept of "state" (stable identity) and "mutability" ("change" as a property of state) is what LiveResponse unifies across the network boundary, or process boundary.
513
+ With LiveResponse, "state" on the server (or in a certain JavaScript process) can be projected (as [above](#1-live-state-projection-via-mutable-response-bodies)) to the client (or another JavaScript process) for a shared identity and continuity.
514
+
515
+ For mutation-based reactivity, LiveResponse is backed by the [Observer](https://github.com/webqit/observer) API. When an object or array is passed as response body, subsequent mutations made to it via the Observer API are observed by LiveResponse and projected
516
+ to the client-side LiveResponse instance.
517
+
518
+ **On the server:**
519
+
520
+ ```js
521
+ const state = { count: 0 };
522
+ const response = new LiveResponse(state);
523
+
524
+ setInterval(() => {
525
+ Observer.set(state, 'count', state.count + 1);
526
+ }, 1000);
527
+
528
+ return response;
529
+ ```
530
+
531
+ **On the client:**
532
+
533
+ ```js
534
+ const response = new LiveResponse(await fetch('http://localhost/counter'));
535
+ const state = (await response.now()).body;
536
+
537
+ Observer.observe(state, () => {
538
+ console.log(state.count); // number
539
+ });
540
+ ```
541
+
542
+ Identity is stable universally, continuity is achieved, and reactive model is shared.
543
+
544
+ Beyond being used to make or observe mutations at the object level, Observer can also be used to observe the response instance
545
+ itself for body-replace events.
546
+
547
+ ```js
548
+ Observer.observe(response, 'body', (m) => {
549
+ console.log(m.oldValue); // null
550
+ console.log(m.value); // { a: 1 }
551
+ });
552
+ ```
553
+
554
+ ```js
555
+ response.replaceWith({ a: 1 });
556
+ ```
557
+
558
+ By comparison, LiveResponse's `"replace"` event fires for the same operation, but emits the fully-resolved response frame in the event:
559
+
560
+ ```js
561
+ response.addEventListener('replace', (e) => {
562
+ console.log(e.data); // { body: { a: 1 }, status: 200, statusText: '', ... }
563
+ });
564
+ ```
565
+
566
+ Another distinction between the two methods is that Observer can observe depth:
567
+
568
+ ```js
569
+ Observer.observe(response, Observer.path('body', 'a', 'b'), (m) => {
570
+ console.log(m.value); // 22
571
+ });
572
+ ```
573
+
574
+ ```js
575
+ response.replaceWith({ a: { b: 22 } });
576
+ ```
577
+
578
+ Of the two, the best approach will depend on use case.
579
+
580
+ ### API Overview
581
+
582
+ `LiveResponse` has an API surface that describes a standard Response object but presents a state-based consumption model
583
+ rather than a stream-based consumption model. It supports the complete set of attributes that defines a response, and exposes
584
+ additional set of APIs for the state model.
585
+
586
+ #### 1. The standard set of attributes (shared by Response and LiveResponse)
587
+
588
+ | API / Feature | LiveResponse | Standard Response |
589
+ | :--------------------------------- | :-------------------- | :--------------------------- |
590
+ | `body` | ✓ (`any`) | ✓ (`ReadableStream`) |
591
+ | `bodyUsed` | ✓ (`boolean`) | ✓ (`boolean`) |
592
+ | `headers` | ✓ (`Headers`) | ✓ (`Headers`) |
593
+ | `status` | ✓ (`number`) | ✓ (`number`) |
594
+ | `statusText` | ✓ (`string`) | ✓ (`string`) |
595
+ | `type` | ✓ (`string`) | ✓ (`string`) |
596
+ | `redirected` | ✓ (`boolean`) | ✓ (`boolean`) |
597
+ | `url` | ✓ (`string`) | ✓ (`string`) |
598
+ | `ok` | ✓ (`boolean`) | ✓ (`boolean`) |
599
+
600
+ **Notes:**
601
+
602
+ + `body` is the direct value of the response instance, as against a `ReadableStream`. For example, `body` is `"Hello World"` for both `new LiveResponse("Hello World")` and `new LiveResponse(new Response("Hello World"))`.
603
+ + `bodyUsed` is always `true`, as LiveResponse has no concept of the stream originally described by this attribute. `bodyUsed` is provided for compatibility with Response.
604
+ + Other attributes are a direct mapping to the corresponding attribute in the given input. For example, `statusText` is `"OK"` for an input like `new LiveResponse("Hello World", { statusText: "OK" })` and `new LiveResponse(new Response("Hello World", { statusText: "OK" }))`.
605
+
606
+ #### 2. The non-applicable stream-based consumption APIs (_not applicable_ to LiveResponse)
607
+
608
+ | API / Feature | LiveResponse | Standard Response |
609
+ | :--------------------------------- | :------------------ | :--------------------------------- |
610
+ | `formData()` | ✗ | ✓ (`Promise<FormData>`) |
611
+ | `json()` | ✗ | ✓ (`Promise<object>`) |
612
+ | `text()` | ✗ | ✓ (`Promise<string>`) |
613
+ | `blob()` | ✗ | ✓ (`Promise<Blob>`) |
614
+ | `arrayBuffer()` | ✗ | ✓ (`Promise<ArrayBuffer>`) |
615
+ | `bytes()` | ✗ | ✓ (`Promise<Uint8Array>`) |
616
+
617
+ **Notes:**
618
+
619
+ + These methods are _not applicable_ to LiveResponse – and thus, not implemented – as it has no concept of a stream, against which these operate.
620
+ + Where desired, LiveResponse offers a `.toResponse()` method that lets you encode the LiveResponse instance back into a standard Response object.
621
+
622
+ #### 3. The state-based consumption APIs (only applicable to LiveResponse)
623
+
624
+ | API / Feature | LiveResponse | Standard Response |
625
+ | :--------------------------------- | :------------------------------ | :--------------------- |
626
+ | `addEventListener()` | ✓ | ✗ |
627
+ | `removeEventListener()` | ✓ | ✗ |
628
+ | `.replaceWith()` | ✓ (`Promise<any>`) | ✗ |
629
+ | `.now()` | ✓ (`Promise<ResponseFrame>`) | ✗ |
630
+
631
+ **Notes:**
632
+
633
+ + `addEventListener()` and `removeEventListener()` lets you listen/unlisten to LiveResponse's `"replace"` events.
634
+ + `.now()` lets you snapshot the state of the instance at the time of call. (Covered [just ahead](#now).)
635
+
636
+ #### 4. Other aspects of the LiveResponse interface
637
+
638
+ The remaining part of the LiveResponse interface includes lifecycle-specific
639
+ APIs like `readyState`, `readyStateChange()`, and `disconnect()`.
640
+
641
+ #### 5. Input Signature
642
+
643
+ LiveResponse implements the same input signature in both its constructor and its `.replaceWith()` method.
644
+ How you use the one is how you use the other.
645
+
646
+ ```js
647
+ // Constructor
648
+ const response = new LiveResponse('Hello World', { headers: { 'Content-Type': 'text/plain' } });
649
+
650
+ // .replaceWith()
651
+ response.replaceWith('Hello Again World', { headers: { 'Content-Type': 'text/plain' } });
652
+ ```
653
+
654
+ As in the standard Response API, the first argument is the response `body` and the second is the `responseInit` object.
655
+
656
+ ##### `body`
657
+
658
+ LiveResponse accepts any JavaScript value as `body`, as long as it has a use case in the application. Strings, numbers, objects, arrays, etc. all work as body types.
659
+ For LiveResponses that cross the wire, body type is implicitly constrained by convertibility to a standard Response body. This is covered in the [encoding](#encoding-back-to-a-standard-response) section.
660
+
661
+ In addition to accepting arbitrary JavaScript values, LiveResponse also accepts:
662
+
663
+ + existing "response" instances – both a standard Response object and a LiveResponse
664
+ instance itself – for cloning or merging. This is covered in the [decoding](#decoding-an-existing-response) section.
665
+ + `Generator` objects and `LiveProgramHandle` objects – as input streams. This is covered in the [decoding](#decoding-generators) section.
666
+
667
+ ##### `responseInit`
668
+
669
+ The `responseInit` object is a superset of the standard _ResponseInit_ object – accepting:
670
+
671
+ + `headers`
672
+ + `status`
673
+ + `statusText`
674
+
675
+ but also other attributes that make it possible to model fetch-generated responses:
676
+
677
+ + `type`
678
+ + `redirected`
679
+ + `url`
680
+
681
+ LiveResponse additionally accepts _lifecycle control_ parameters here:
682
+
683
+ + `done`
684
+ + `concurrent`
685
+
686
+ ### Decoding an Existing Response
687
+
688
+ An existing response instance can be passed to LiveResponse for decoding or merging into the LiveResponse instance.
689
+ Both a standard Response object and a LiveResponse instance itself are supported:
690
+
691
+ ```js
692
+ // Clone a standard response instance
693
+ const response1 = new LiveResponse(new Response('Hello from server'));
694
+ console.log((await response.now()).body); // 'Hello from server'
695
+
696
+ // Clone a LiveResponse instance
697
+ const response2 = new LiveResponse(response1);
698
+ console.log((await response.now()).body); // 'Hello from server'
699
+
700
+ // Flatten-in a standard response instance
701
+ await response2.replaceWith(new Response('Hello again from server'));
702
+ console.log(response2.body); // 'Hello again from server'
703
+
704
+ // Flatten-in a LiveResponse instance
705
+ await response2.replaceWith(new LiveResponse('Hello finally from server'));
706
+ console.log(response2.body); // 'Hello finally from server'
707
+ ```
708
+
709
+ When passed a standard Response object, LiveResponse does a direct instance mapping of the given response. For the body, it automatically reads the body stream of the response and takes the result.
710
+
711
+ > [!TIP]
712
+ >
713
+ > The reading algorithm is:
714
+ >
715
+ > + Try to decode the data as JSON. (This succeeds for `Content-Type: application/json | multipart/form-data | application/x-www-form-urlencoded`. LiveResponse internally uses `ResponsePlus.prototype.any.call(response, { to: 'json' })` for this. This API is covered [below](#the-any-instance-method).)
716
+ > + If that fails, try to decode the data to the most appropriate result type for the given content type; e.g. "text" for `Content-Type: text/*`; `Blob` for `Content-Type: image/*`; etc. (LiveResponse internally uses `ResponsePlus.prototype.any.call(response)` for this. This API is covered [below](#the-any-instance-method).)
717
+ > + Map the result to the `body` attribute.
718
+
719
+ On success, LiveResponse inspects the response headers for the presence of the `X-Message-Port` header. If present, LiveResponse
720
+ automatically connects to the port specified by the header and begins a real time mirroring of the original response. The completion of this cycle is covered in the [Response-Frame Cycle](#2-the-response-frame-cycle) section.
721
+
722
+ When passed a LiveResponse instance itself, LiveResponse does a direct instance mapping of the given response. Next, LiveResponse automatically binds to the instance's change events and begins a real time mirroring of the response. The completion of this cycle is also covered in the [Response-Frame Cycle](#2-the-response-frame-cycle) section.
723
+
724
+ ### Decoding Generators
725
+
726
+ LiveResponse's transitions can be directly driven by a JavaScript `Generator` or a [`LiveProgramHandle`](https://github.com/webqit/use-live) object.
727
+
728
+ When passed a `Generator` instance, LiveResponse consumes the stream asynchronously and maps each yielded value to a response frame:
729
+
730
+ ```js
731
+ const response = LiveResponse(
732
+ (async function*() {
733
+ const frame1 = new Promise((resolve) => setTimeout(() => resolve('frame 1'), 100));
734
+ yield frame1;
735
+ // 100ms later
736
+ yield 'frame 2';
737
+ // Immediately after
738
+ const frame3 = new Promise((resolve) => setTimeout(() => resolve('frame 3'), 100));
739
+ return frame3;
740
+ })()
741
+ );
742
+ setTimeout(() => console.log(response.body), 300); // 'frame 3'
743
+ ```
744
+
745
+ When passed a `LiveProgramHandle` object, LiveResponse observes the Handle's `value` property and maps each emmission to a response frame:
746
+
747
+ ```js
748
+ const response = LiveResponse(
749
+ (function() {
750
+ "use live";
751
+
752
+ let count = 0;
753
+ setInterval(() => count++, 1000);
754
+
755
+ return count;
756
+ })()
757
+ );
758
+ setTimeout(() => console.log(response.body), 2000); // 2
759
+ ```
760
+
761
+ > [!IMPORTANT]
762
+ >
763
+ > Support for `LiveProgramHandle` objects is experimental and may change.
764
+
765
+ In both cases, the resulting value in each yield goes to the `body` of the resulting response frame.
766
+ If the said resulting value is a response instance itself, it flattens directly into the LiveResponse instance as described
767
+ in the [decoding](#decoding-an-existing-response) section:
768
+
769
+ ```js
770
+ const response = LiveResponse(
771
+ (async function*() {
772
+ const frame1 = new Response('frame 1', { status: 201 });
773
+ yield frame1;
774
+ // 100ms later
775
+ yield 'frame 2';
776
+ // Immediately after
777
+ const frame3 = new Response('frame 1', { headers: { 'Content-Type': 'text/custom' } });
778
+ return frame3;
779
+ })()
780
+ );
781
+ setTimeout(() => console.log(response.headers.get('Content-Type')), 300); // 'text/custom'
782
+ ```
783
+
784
+ The completion of this cycle is covered in the [Response-Frame Cycle](#2-the-response-frame-cycle) section.
785
+
786
+ ### Encoding Back to a Standard Response
787
+
788
+ The `.toResponse()` method can be used to encode a LiveResponse instance into a standard Response instance.
789
+ The encoding includes formatting the `body` value to the corresponding body type accepted by the Response API – where needed.
790
+ LiveResponse internally uses the [`ResponsePlus.from()`](#the-from-static-method) method for this.
791
+
792
+ Strings, for example, are native Response body types and are, therefore, passed untransformed to the standard Response constructor:
793
+
794
+ ```js
795
+ const response = new LiveResponse('Hello world');
796
+ const whatwgResponse = response.toResponse();
797
+
798
+ console.log(await whatwgResponse.text()); // 'Hello world'
799
+ ```
800
+
801
+ For unusual value types, like functions, Symbols, etc., that may make it to a LiveResponse instance as body,
802
+ the success of the transition from a LiveResponse to a standard Response instance will depend on whether the given value type is accepted by the Response API or,
803
+ at least, handled in the `ResponsePlus.from()` algorithm. For example, while "function" types aren't handled in the algorithm, they
804
+ naturally get serialized as strings by the Response API itself:
805
+
806
+ ```js
807
+ // Functions serialize well as strings. Symbols fail
808
+ console.log(await new LiveResponse(() => 3).toResponse().text()); // '() => 3'
809
+ ```
810
+
811
+ Structured value types like objects and arrays are handled in the `ResponsePlus.from()` algorithm. They are formatted as JSON strings – along with the relevant headers. But when they contain
812
+ special object types like Blobs, the algorithm smartly encodes them as "multipart/formdata" payloads instead:
813
+
814
+ ```js
815
+ const body1 = { a: 1, b: 2 };
816
+ // Plain JSON payload
817
+ console.log(await new LiveResponse(body1).toResponse().headers.get('Content-Type')); // 'application/json'
818
+ ```
819
+
820
+ ```js
821
+ const body1 = { a: 1, b: new Blob([bytes]) };
822
+ // Multipart/FormData payload
823
+ console.log(await new LiveResponse(body1).toResponse().headers.get('Content-Type')); // 'multipart/formdata;...'
824
+ ```
825
+
826
+ The return value of the `.toResponse()` method is a standard Response instance – more specifically, a [`ResponsePlus`](#request-and-response-interfaces-with-sensible-defaults---requestplus-and-responseplus) instance.
827
+
828
+ ### Lifecycles
829
+
830
+ #### 1. The Ready-State Cycle
831
+
832
+ A LiveResponse instance transitions through three states in its lifetime:
833
+
834
+ - **`waiting`**: The initial frame is still resolving
835
+ - **`live`**: The initial frame has resolved and is effective on the instance
836
+ - **`done`**: Final frame has resolved and is effective on the instance, no more "replace" operations expected
837
+
838
+ For _synchronously-resolvable_ inputs like strings and objects, the instance transitions to `live` synchronously:
839
+
840
+ ```js
841
+ const response = new LiveResponse('Initial frame');
842
+ console.log(response.readyState); // "live"
843
+ ```
844
+
845
+ It transitions to "done" at `Promise.resolve()` timing:
846
+
847
+ ```js
848
+ Promise.resolve().then(() => console.log(response.readyState)); // "done"
849
+ // Or simply:
850
+ // await Promise.resolve();
851
+ // console.log(response.readyState); // "done"
852
+ ```
853
+
854
+ For _asynchronously-resolved_ inputs like promise-wrapped values and Response instances, the instance transitions to `live` at the resolution timing of the input:
855
+
856
+ ```js
857
+ const response = new LiveResponse(Promise.resolve('Initial frame'));
858
+ console.log(response.readyState); // "waiting"
859
+
860
+ Promise.resolve().then(() => console.log(response.readyState)); // "live"
861
+ // Or simply:
862
+ // await Promise.resolve();
863
+ // console.log(response.readyState); // "live"
864
+ ```
865
+
866
+ It transitions to "done" at 2 x `Promise.resolve()` timing:
867
+
868
+ ```js
869
+ Promise.resolve().then(() => Promise.resolve().then(() => console.log(response.readyState))); // "done"
870
+ // Or simply:
871
+ // await Promise.resolve();
872
+ // await Promise.resolve();
873
+ // console.log(response.readyState); // "done"
874
+ ```
875
+
876
+ The `.readyStateChange()` method can be used to await ready-state transitions. This method returns a Promise (the same instance each time) that resolves to the LiveResponse instance itself when the ready state transitions to the specified state:
877
+
878
+ ```js
879
+ await response.readyStateChange('live'); // Resolves when ready-state transitions to "live"
880
+ await response.readyStateChange('done'); // Resolves when ready-state transitions to "done"
881
+ ```
882
+
883
+ The ready-state completion of the instance can be controlled via the `responseInit.done` parameter. When `false`, the response is kept open to further replacements – via `.replaceWith()`.
884
+ When `true`, the instance is treated as finalized at the _end_ of the current frame's cycle. The instance's ready state transitions to `done` and no further replacement is permitted. When omitted, `done: true` is implied.
885
+
886
+ ```js
887
+ const response = new LiveResponse('Initial frame', { done: false }); // Remains open
888
+ console.log(response.readyState); // "live"
889
+
890
+ response.replaceWith('Intermediate frame', { done: false }); // Remains open
891
+ console.log(response.readyState); // "live"
892
+
893
+ response.replaceWith('Final frame'); // Transitions to "done" at Promise.resolve() timing
894
+
895
+ await Promise.resolve();
896
+ console.log(response.readyState); // "done"
897
+ ```
898
+
899
+ The ready-state's transition to "done" happens _at the end_ of the active frame's cycle – obvious for async inputs:
900
+
901
+ ```js
902
+ const response = new LiveResponse('Initial frame', { done: false }); // Remains open
903
+ console.log(response.readyState); // "live"
904
+
905
+ const finalFrame = new Promise((r) => setTimeout(() => r('Final frame'), 100));
906
+ response.replaceWith(finalFrame); // Transitions to "done" AFTER promise resolves in 100ms
907
+
908
+ await Promise.resolve();
909
+ console.log(response.readyState); // "live"
910
+
911
+ await new Promise((r) => setTimeout(r, 100));
912
+ console.log(response.readyState); // "done"
913
+ ```
914
+
915
+ If a new "replace" operation is made before the ready-state's transition to "done", the incoming frame takes over the ready-state:
916
+
917
+ ```js
918
+ const response = new LiveResponse('Initial frame', { done: false }); // Remains open
919
+ console.log(response.readyState); // "live"
920
+
921
+ const finalFrame = new Promise((r) => setTimeout(() => r('Final frame'), 100));
922
+ response.replaceWith(finalFrame); // Transitions to "done" AFTER promise resolves in 100ms
923
+
924
+ await Promise.resolve();
925
+ console.log(response.readyState); // "live"
926
+
927
+ response.replaceWith('Final final frame'); // Takes over the ready-state; transitions to "done" MUCH SOONER
928
+
929
+ await Promise.resolve();
930
+ console.log(response.readyState); // "done"
931
+ ```
932
+
933
+ ```js
934
+ const response = new LiveResponse('Initial frame', { done: false }); // Remains open
935
+ console.log(response.readyState); // "live"
936
+
937
+ response.replaceWith(Promise.resolve('Final frame')); // Transitions to "done" after promise resolves
938
+ console.log(response.readyState); // "live"
939
+
940
+ const finalFinalFrame = new Promise((r) => setTimeout(() => r('Final final frame'), 100));
941
+ response.replaceWith(finalFinalFrame); // Takes over the ready-state; transitions to "done" MUCH LATER – being an asynchronous input
942
+
943
+ await Promise.resolve();
944
+ console.log(response.readyState); // "live"
945
+
946
+ await new Promise((r) => setTimeout(r, 100));
947
+ console.log(response.readyState); // "done"
948
+ ```
949
+
950
+ **Summary:**
951
+
952
+ + At any point, `.readyState` answers "What state is the instance in now?" (`waiting` | `live` | `done`)
953
+ + `.readyStateChange()` says "Give me a promise that resolves when the instance transitions to..."
954
+ + `responseInit.done = false` says "Keep the instance alive for future replacements"
955
+ + `.replaceWith()` takes over Ready State on each call; throws when called after instance reaches "done"
956
+
957
+ #### 2. The Response-Frame Cycle
958
+
959
+ "Response-Frame" refers to the _semantic response_ modelled by a LiveResponse instance at any point in time. The first semantic response
960
+ is defined by the arguments passed at instantiation, and a new semantic response is assumed on each replacement. That equates to, at least, two response frames.
961
+
962
+ ```js
963
+ const response = new LiveResponse('Initial frame', { done: false }); // Frame 1
964
+ response.replaceWith('Another frame'); // Frame 2
965
+ ```
966
+
967
+ For _synchronously-resolvable_ inputs like strings and objects, inputs reflect synchronously on the instance:
968
+
969
+ ```js
970
+ const response = new LiveResponse('Hello World', { headers: { 'Content-Type': 'text/plain' } });
971
+
972
+ console.log(response.body); // "Hello World"
973
+ console.log(response.headers.get('Content-Type')); // "text/plain"
974
+
975
+ response.replaceWith('Hello again World', { headers: { 'Content-Type': 'foo/bar' } });
976
+
977
+ console.log(response.body); // "Hello again World"
978
+ console.log(response.headers.get('Content-Type')); // "foo/bar"
979
+ ```
980
+
981
+ For _asynchronously-resolved_ inputs like promise-wrapped values and Response instances, inputs reflect on the instance at the resolution timing of the input:
982
+
983
+ ```js
984
+ const response = new LiveResponse(Promise.resolve('Hello World'), { headers: { 'Content-Type': 'text/plain' } });
985
+
986
+ // Direct access sees nothing yet
987
+ console.log(response.body); // null
988
+ console.log(response.headers.get('Content-Type')); // null
989
+
990
+ Promise.resolve().then(() => {
991
+ console.log(response.body); // "Hello World"
992
+ console.log(response.headers.get('Content-Type')); // "text/plain"
993
+ });
994
+ // Or simply:
995
+ // await Promise.resolve();
996
+ // console.log(response.body); // "Hello World"
997
+ // console.log(response.headers.get('Content-Type')); // "text/plain"
998
+ ```
999
+
1000
+ The timing between when a frame is issued, reflected, and replaced is the Response-Frame Cycle.
1001
+
1002
+ The `.now()` method lets you snapshot the state of the instance at the time of call, regardless of the resolution phase of the most current frame.
1003
+ This method returns a Promise that resolves to a `ResponseFrame` object – the fully resolved input "frame".
1004
+
1005
+ ```js
1006
+ const response = new LiveResponse(new Response('Hello World'), { headers: { 'Content-Type': 'text/plain' } });
1007
+
1008
+ // Direct access sees nothing yet
1009
+ console.log(response.body); // null
1010
+ console.log(response.headers.get('Content-Type')); // null
1011
+
1012
+ // .now() snapshots the resolving frame
1013
+ console.log((await response.now()).body); // "Hello World"
1014
+ console.log((await response.now()).headers.get('Content-Type')); // "text/plain"
1015
+ ```
1016
+
1017
+ As a general rule, `.now()` snapshots at _call time_ and resolves at _resolution time_.
1018
+ This means `.now()` gives predictable results regardless of the resolution timing of the input.
1019
+
1020
+ In a sequence of "replace" operations, for example, a previous replacement, if asynchronous, may yet be resolving when the next comes, and if so, is abandoned for the next. `.now()` resolves predictably even on abandoned frames.
1021
+
1022
+ ```js
1023
+ const frame1 = new Promise((resolve) => setTimeout(() => resolve('frame 1'), 10));
1024
+
1025
+ const response = new LiveResponse(frame1, { done: false });
1026
+ const snapshot1 = response.now(); // Snapshot 'frame 1' while still resolving
1027
+
1028
+ response.replaceWith('frame 2', { done: false }); // 'frame 1' is abandoned now while still resolving
1029
+ const snapshot2 = response.now(); // Snapshot 'frame 2'
1030
+
1031
+ const frame3 = new Promise((resolve) => setTimeout(() => resolve('frame 3'), 10));
1032
+ response.replaceWith(frame3, { done: false }); // 'frame 2' – which resolved synchronously – is replaced now
1033
+ const snapshot3 = response.now(); // Snapshot 'frame 3' while still resolving
1034
+
1035
+ const frame4 = new Promise((resolve) => setTimeout(() => resolve('frame 4'), 10));
1036
+ response.replaceWith(frame4, { done: true }); // 'frame 3' is abandoned now while still resolving
1037
+ const snapshot4 = response.now(); // Snapshot 'frame 4' while still resolving
1038
+
1039
+ console.log((await snapshot1).body); // 'frame 1'
1040
+ console.log((await snapshot2).body); // 'frame 2'
1041
+ console.log((await snapshot3).body); // 'frame 3'
1042
+ console.log((await snapshot4).body); // 'frame 4'
1043
+ ```
1044
+
1045
+ In all cases, too, `.replaceWith()` returns a Promise that resolves to `true` when the frame cycle completes.
1046
+
1047
+ ```js
1048
+ const frame5 = new Promise((resolve) => setTimeout(() => resolve('frame 5'), 10));
1049
+ await response.replaceWith(frame5, { done: false });
1050
+
1051
+ console.log(response.body); // 'frame 5'
1052
+ ```
1053
+
1054
+ For multi-frame inputs like `Generators`, `.replaceWith()` resolves at the resolution timing of the last subframe.
1055
+ This is when the frame cycle is considered complete from the perspective of the caller, making it easy to coordinate subsequent replacements.
1056
+
1057
+ For example, `replaceStatus5_7` below, resolves `200+ms` later:
1058
+
1059
+ ```js
1060
+ const replaceStatus5_7 = await response.replaceWith(
1061
+ (async function*() {
1062
+ const frame5 = new Promise((resolve) => setTimeout(() => resolve('frame 5'), 100));
1063
+ yield frame5;
1064
+ // 100ms later
1065
+ yield 'frame 6';
1066
+ // Immediately after
1067
+ const frame7 = new Promise((resolve) => setTimeout(() => resolve('frame 7'), 100));
1068
+ return frame7;
1069
+ })(),
1070
+ { done: false } // Keep the instance open even after frame 7
1071
+ );
1072
+ // About 200+ms later
1073
+ console.log(replaceStatus5_7); // true
1074
+
1075
+ // We can replace now
1076
+ const replaceStatus8 = await response.replaceWith('frame 8');
1077
+ console.log(replaceStatus8); // true
1078
+ ```
1079
+
1080
+ `replaceStatus9` below, resolves when the series of responses from upstream – over the specified `X-Message-Port` – is complete, or when the port closes:
1081
+
1082
+ ```js
1083
+ const upstreamResponse = new Response('frame 9', { headers: { 'X-Message-Port': 'socket:///?port_id=fedkdkjd43' }});
1084
+ const replaceStatus9 = await response.replaceWith(
1085
+ upstreamResponse,
1086
+ { done: false } // Keep the instance open even after cycle completes
1087
+ );
1088
+ // After cycle completes
1089
+ console.log(replaceStatus9); // true
1090
+
1091
+ // We can replace now
1092
+ const replaceStatus10 = await response.replaceWith('frame 10');
1093
+ console.log(replaceStatus10); // true
1094
+ ```
1095
+
1096
+ `replaceStatus11` below, resolves when the specified LiveResponse input completes its lifecycle – that is, transitions to "done":
1097
+
1098
+ ```js
1099
+ const nestedLiveResponse = LiveResponse.from(fetch('http://localhost/hello'));
1100
+ const replaceStatus11 = await response.replaceWith(
1101
+ nestedLiveResponse,
1102
+ { done: false } // Keep the instance open even after cycle completes
1103
+ );
1104
+ // After cycle completes
1105
+ console.log(replaceStatus11); // true
1106
+
1107
+ // We can replace now
1108
+ const replaceStatus12 = await response.replaceWith('frame 12');
1109
+ console.log(replaceStatus12); // true
1110
+ ```
1111
+
1112
+ In all cases, however, the Promise returned by `.replaceWith()` resolves _sooner_ to `false` when a new `.replaceWith()` call is made, or `.disconnect()` is called, before the frame cycle completes:
1113
+
1114
+ ```js
1115
+ // Going live after 100ms...
1116
+ const frame5 = new Promise((resolve) => setTimeout(() => resolve('frame 5'), 100));
1117
+
1118
+ const replacePromise5 = response.replaceWith(frame5, { done: false });
1119
+ const snapshot5 = response.now();
1120
+
1121
+ replacePromise5.then((status) => console.log('Did we go live?', status));
1122
+ snapshot5.then(() => console.log('We resolved at our 100ms timing tho'));
1123
+
1124
+ // Wait 50ms and blow out the yet resolving frame 5
1125
+ await new Promise((resolve) => setTimeout(() => resolve('frame 5'), 50));
1126
+ response.replaceWith('frame 6');
1127
+
1128
+ // After 50ms: 'Did we go live?' false
1129
+ // After 100ms: 'We resolved at our 100ms timing tho'
1130
+ ```
1131
+
1132
+ **Summary:**
1133
+
1134
+ + At any point, `.now()` helps ensure that you are accessing the instance with the most current frame already "live" on the instance.
1135
+ + It is also useful for "Give me a Promise that resolves when my last `.replaceWith()` call resolves" – whether it indeed goes live on the instance or not
1136
+ + The promise returned by `.replaceWith()` is also useful for "Give me a Promise that resolves when the given input resolves"
1137
+ + But it additionally answers "Did that successfully go live on the instance or was it abandoned for a newer `.replaceWith()` or `.disconnect()` call?"
1138
+ + While `.now()` and `.replaceWith()` may resolve equally at the resolution timing of certain inputs, they don't always – as they are designed to answer different questions.
1139
+ + For multi-frame inputs like `Generators`, `.replaceWith()` resolves at the resolution timing of the last frame. `.now()` resolves at that of the first
1140
+ + `.replaceWith()` may resolve sooner if superseded by another `.replaceWith()` call, or abandoned via `.disconnect()`, before completion
1141
+
1142
+ #### 3. The Live-State Projection Cycle
1143
+
1144
+ When LiveResponse [projects live state](#1-live-state-projection-via-mutable-response-bodies) across the wire, the state remains live until
1145
+ the next `.replaceWith()` call – which establishes a new response frame and a new state. On the client, the replaced state
1146
+ stops reflecting mutations made on the server. But it also can be kept alive concurrently with the new state. This is done by passing a `concurrent: true` flag
1147
+ with the new "replace" operation:
1148
+
1149
+ ```js
1150
+ const initialState = { count: 0 };
1151
+ const response = new LiveResponse(initialState);
1152
+
1153
+ // Counter
1154
+ setInterval(() => {
1155
+ Observer.set(initialState, 'count', initialState.count + 1);
1156
+ }, 1000);
1157
+
1158
+ // Later
1159
+ setTimeout(() => {
1160
+ response.replaceWith('Hello Now', { concurrent: true });
1161
+ console.log(response.concurrent); // true
1162
+ }, 10_000);
1163
+
1164
+ // Return response for sending over the network
1165
+ return response;
1166
+ ```
1167
+
1168
+ With `concurrent: true`, the counter above will continue unstopped on the client side even when the response is replaced:
1169
+
1170
+ ```js
1171
+ const response = LiveResponse.from(fetch('http://localhost/counter'));
1172
+ const initialState = (await response.now()).body;
1173
+
1174
+ Observer.observe(initialState, 'count', () => {
1175
+ console.log(initialState.count);
1176
+ });
1177
+
1178
+ response.addEventListener('replace', () => {
1179
+ // 'Hello Now' has arrived and should specify "concurrent: true"
1180
+ console.log(response.body); // 'Hello Now'
1181
+ console.log(response.concurrent); // true
1182
+ });
1183
+ ```
1184
+
1185
+ ---
1186
+
1187
+ ## _Section 2_: Fetch API Extensions
1188
+
1189
+ Fetch+ introduces a small set of in-place extensions to the core Fetch primitives—`Request`, `Response`, `Headers`, `FormData`, and `fetch()`—to provide a more semantic and developer-friendly API surface.
1190
+
1191
+ ### Request and Response Interfaces with Sensible Defaults – `RequestPlus` and `ResponsePlus`
1192
+
1193
+ `RequestPlus` and `ResponsePlus` are extensions of the `Request` and `Response` interfaces that add support for type-agnostic
1194
+ body parsing and a factory method with sensible defaults. These methods are:
1195
+
1196
+ + `RequestPlus.prototype.any()` / `ResponsePlus.prototype.any()`
1197
+ + `RequestPlus.from()` / `ResponsePlus.from()`
1198
+ + `RequestPlus.copy()`
1199
+
1200
+ #### The `.any()` Instance Method
1201
+
1202
+ **APIs**: `RequestPlus.prototype.any()` / `ResponsePlus.prototype.any()`
1203
+
1204
+ The `.any()` instance method is an addition to the existing list of request/response body readers – `.text()`, `.json()`, `.arrayBuffer()`, `.blob()`, `.formData()`, and `.bytes()`.
1205
+ `.any()` works as a unified, content-type-aware body reader. By default, it auto-infers the body type from the instance's `Content-Type` header and dispatches to the appropriate reader – yielding:
1206
+
1207
+ + result type `FormData` – for content-type `multipart/form-data` | `application/x-www-form-urlencoded`
1208
+ + result type JSON object – for content-type `application/json`
1209
+ + result type string – for content-type `text/*` | `application/javascript` | `application/*xml*`
1210
+ + result type `Blob` – for content-type `image/*` | `audio/*` | `video/*` | `application/*` (excluding: `application/*xml*` | `application/*json*` | `application/*javascript*` | `application/*x-www-form-urlencoded*`)
1211
+ + result type `Uint8Array` – for other content-types, e.g. `application/octet-stream`
1212
+
1213
+ with support for explicit type selection via an options parameter.
1214
+
1215
+ **Signature**:
1216
+
1217
+ + `.any()`: `Promise<any>`
1218
+ + `.any({ to?, memo? })`: `Promise<any>`
1219
+
1220
+ **Options**:
1221
+
1222
+ + `to`: `"arrayBuffer"` | `"blob"` | `"formData"` | `"json"` | `"text"` | `"bytes"`
1223
+ + `memo`: `boolean` Controls whether to memoize the result. When true, the result is cached and returned on subsequent calls.
1224
+
1225
+ **Example 1: _Auto type detection_**
1226
+
1227
+ Call `.any()` and get back a corresponding result type for the specific content type of the request or response.
1228
+
1229
+ ```js
1230
+ // For content-type `multipart/form-data` | `application/x-www-form-urlencoded`
1231
+ const body = await response.any(); // FormData
1232
+
1233
+ // For content-type `application/json`
1234
+ const body = await response.any(); // JSON object
1235
+
1236
+ // For content-type `text/*` | `application/javascript` | `application/*xml*`
1237
+ const body = await response.any(); // text
1238
+
1239
+ // For content-type `image/*` | `audio/*` | `video/*` | `application/*` (excluding: `application/*xml*` | `application/*json*` | `application/*javascript*` | `application/*x-www-form-urlencoded*`)
1240
+ const body = await response.any(); // Blob
1241
+
1242
+ // For other content-types, e.g. `application/octet-stream`
1243
+ const body = await response.any(); // Uint8Array
1244
+ ```
1245
+
1246
+ **Example 2: _Explicit type selection/coercion_**
1247
+
1248
+ Explicitly pass a type to `.any()` at any time.
1249
+
1250
+ ```js
1251
+ // For content-type `application/json` | `application/x-www-form-urlencoded` | `multipart/form-data`
1252
+ const body = await response.any({ to: 'json' }); // JSON object
1253
+ const body = await response.any({ to: 'formData' }); // FormData
1254
+
1255
+ // For ALL Content-Types, including `application/json` | `application/x-www-form-urlencoded` | `multipart/form-data`
1256
+ const body = await response.any({ to: 'text' }); // text
1257
+ const body = await response.any({ to: 'arrayBuffer' }); // ArrayBuffer
1258
+ const body = await response.any({ to: 'blob' }); // Blob
1259
+ const body = await response.any({ to: 'bytes' }); // Uint8Array
1260
+ ```
1261
+
1262
+ **Notes:**
1263
+
1264
+ + Type coercion to structured formats – `json` | `formData` – is supported for any of `application/json` | `application/x-www-form-urlencoded` | `multipart/form-data`. In other words, any of the these three payload types can be cast to `json` or `formData` interchangeably.
1265
+ + Type coercion to unstructured formats – `text` | `arrayBuffer` | `blob` | `bytes` – is supported for ALL content-types, including `application/json` | `application/x-www-form-urlencoded` | `multipart/form-data`.
1266
+
1267
+ **Example 3: _Memoization_**
1268
+
1269
+ Opt in to memoization to enable multiple instance reads.
1270
+
1271
+ ```js
1272
+ const body = await response.any({ memo: true }); // Actively parsed on first call and memoized for subsequent calls
1273
+ const body = await response.any({ memo: true }); // Returns cached result
1274
+ ```
1275
+ **Notes:**
1276
+
1277
+ + With `memo: true`, an automatic clone of the instance is kept the first time instance is read to support future reads.
1278
+ + Results are also memoized – by type – the first time the specified type is processed.
1279
+ + For results of type `json` | `formData`, the result of each call is a _copy_ of the cached. For other types, the result of each call _is_ the cached.
1280
+ + Cache can be cleared at any time by calling `.forget()` on the instance. (A synchronous method.)
1281
+
1282
+ **Example 4: _Direct Instantiation_**
1283
+
1284
+ Use `RequestPlus` and `ResponsePlus` in code by directly instantiating them.
1285
+
1286
+ ```js
1287
+ import { RequestPlus, ResponsePlus } from 'fetch-plus';
1288
+ ```
1289
+
1290
+ ```js
1291
+ const jsonObject = {
1292
+ name: 'John Doe',
1293
+ email: 'john.doe@example.com'
1294
+ };
1295
+ ```
1296
+
1297
+ ```js
1298
+ const request = new RequestPlus(url, {
1299
+ method: 'POST',
1300
+ body: JSON.stringify(jsonObject),
1301
+ headers: { 'Content-Type': 'application/json' }
1302
+ });
1303
+ const jsonObject = await request.any();
1304
+ const jsonObject = await request.any({ to: 'json' });
1305
+ const formData = await request.any({ to: 'formData' });
1306
+ ```
1307
+
1308
+ ```js
1309
+ const response = new ResponsePlus(JSON.stringify(jsonObject), {
1310
+ headers: { 'Content-Type': 'application/json' }
1311
+ });
1312
+ const jsonObject = await response.any();
1313
+ const jsonObject = await response.any({ to: 'json' });
1314
+ const formData = await response.any({ to: 'formData' });
1315
+ ```
1316
+
1317
+ `fetchPlus()` is also provided as a direct entry point to `ResponsePlus`. (`fetchPlus()` returns an instance of `ResponsePlus`.)
1318
+
1319
+ ```js
1320
+ // Using fetchPlus() for auto-upgraded response instances
1321
+ import { fetchPlus } from 'fetch-plus';
1322
+
1323
+ const response = await fetchPlus(url); // Auto-upgraded response instance
1324
+
1325
+ const jsonObject = await response.any();
1326
+ const jsonObject = await response.any({ to: 'json' });
1327
+ const formData = await response.any({ to: 'formData' });
1328
+ ```
1329
+
1330
+ **Example 5: _Upgrade Paths for Existing Request/Response Instances_**
1331
+
1332
+ Cast existing request/response instance to `RequestPlus` or `ResponsePlus` using their respective `.upgradeInPlace()` static methods.
1333
+
1334
+ ```js
1335
+ // For existing request instances – in a service worker, for example
1336
+ import { RequestPlus } from 'fetch-plus';
1337
+
1338
+ self.addEventListener('fetch', (event) => {
1339
+ const request = event.request;
1340
+ // Upgrade to RequestPlus
1341
+ RequestPlus.upgradeInPlace(request);
1342
+
1343
+ event.respondWith((async () => {
1344
+ const body = await request.any({ to: 'json' });
1345
+ if (body.name === 'John Doe') {
1346
+ return new Response(JSON.stringify({ message: 'Hello, John Doe!' }));
1347
+ }
1348
+ return new Response(JSON.stringify({ message: 'Hello, World!' }));
1349
+ })());
1350
+ });
1351
+ ```
1352
+
1353
+ ```js
1354
+ // For existing response instances – in a service worker, for example
1355
+ import { ResponsePlus } from 'fetch-plus';
1356
+
1357
+ self.addEventListener('fetch', (event) => {
1358
+ const request = event.request;
1359
+
1360
+ event.respondWith((async () => {
1361
+ const response = await fetch(request);
1362
+ // Upgrade to ResponsePlus
1363
+ ResponsePlus.upgradeInPlace(response);
1364
+
1365
+ const body = await response.any({ to: 'json' });
1366
+ if (body.name === 'John Doe') {
1367
+ return new Response(JSON.stringify({ message: 'Hello, John Doe!' }));
1368
+ }
1369
+ return new Response(JSON.stringify({ message: 'Hello, World!' }));
1370
+ })());
1371
+ });
1372
+ ```
1373
+
1374
+ #### The `.from()` Static Method
1375
+
1376
+ **APIs**: `RequestPlus.from()` / `ResponsePlus.from()`
1377
+
1378
+ The `.from()` static method is a factory method for creating new Request/Response instances directly from application data – JSON objects, strings, etc. – without the strict formatting requirement of the `Request`/`Response` constructors.
1379
+ `.from()` automatically converts the given input to the required payload format and auto-adds the corresponding `Content-Type` header (and the `Content-Length` header, where possible) – yielding:
1380
+
1381
+ + body type `FormData` with content-type `"multipart/form-data"` – for `FormData` inputs and JSON object inputs containing complex data types like `Blobs`
1382
+ + body type JSON string with content-type `"application/json"` – and the appropriate content-length value – for plain JSON object inputs
1383
+ + body type `Blob` with content-type `blob.type` – and content-length `blob.size` – for `Blob` inputs
1384
+ + body type `Uint8Array` | `Uint16Array` | `Uint32Array` | `ArrayBuffer` with content-type `"application/octet-stream"` – and content-length `"array.byteLength"` – for `TypedArray` inputs
1385
+ + other body types with content-type `"application/octet-stream"` – and the corresponding content-length value – for other inputs
1386
+
1387
+ **Signature**:
1388
+
1389
+ + `RequestPlus.from(url, requestInit)`: `RequestPlus`
1390
+ + `ResponsePlus.from(data, responseInit)`: `ResponsePlus`
1391
+
1392
+ **Options**:
1393
+
1394
+ + `init.memo`: `boolean` Controls whether to memoize the given input for direct retrieval on future `.any()` calls. When true, the input is cached and returned on calls to `.any()` – skipping the more expensive body traversal route.
1395
+
1396
+ **Example 1: _Auto input formatting_**
1397
+
1398
+ Create Request/Response instances directly from application data.
1399
+
1400
+ ```js
1401
+ // FormData
1402
+ const request = RequestPlus.from(url, { body: new FormData() });
1403
+ // Auto Content-Type: `multipart/form-data`
1404
+
1405
+ // JSON object with complex data types
1406
+ const request = RequestPlus.from(url, { body: {
1407
+ name: 'John Doe',
1408
+ avatars: {
1409
+ primary: new Blob([imageBytes1], { type: 'image/png' }),
1410
+ secondary: new Blob([imageBytes2], { type: 'image/png' }),
1411
+ },
1412
+ loves_it: true,
1413
+ } });
1414
+ // Auto Content-Type: `multipart/form-data`
1415
+
1416
+ // Plain JSON object
1417
+ const request = RequestPlus.from(url, { body: { name: 'John Doe', email: 'john.doe@example.com' } });
1418
+ // Auto Content-Type: `application/json`
1419
+ // Auto Content-Length: <number>
1420
+
1421
+ // string
1422
+ const request = RequestPlus.from(url, { body: 'Hello, World!' });
1423
+ // Auto Content-Type: `text/plain`
1424
+ // Auto Content-Length: <number>
1425
+
1426
+ // TypeArray
1427
+ const request = RequestPlus.from(url, { body: new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) });
1428
+ // Auto Content-Type: `application/octet-stream`
1429
+ // Auto Content-Length: <number>
1430
+
1431
+ // Blob
1432
+ const request = RequestPlus.from(url, { body: new Blob(['hello'], { type: 'text/plain' }) });
1433
+ // Auto Content-Type: `text/plain`
1434
+ // Auto Content-Length: <number>
1435
+ ```
1436
+
1437
+ **Example 2: _Memoization_**
1438
+
1439
+ Opt in to memoization for given input to enable multiple instance reads from the start.
1440
+
1441
+ ```js
1442
+ // FormData
1443
+ const request = RequestPlus.from(url, { body: new FormData(), memo: true });
1444
+ await request.any({ memo: true });
1445
+ // Copy of original formData
1446
+
1447
+ // JSON object
1448
+ const request = RequestPlus.from(url, { body: { name: 'John Doe', email: 'john.doe@example.com' }, memo: true });
1449
+ await request.any({ memo: true });
1450
+ // Copy of original JSON object
1451
+
1452
+ // JSON object
1453
+ const request = RequestPlus.from(url, { body: { name: 'John Doe', email: 'john.doe@example.com' }, memo: true });
1454
+ await request.any({ to: 'bytes', memo: true });
1455
+ // Bytes from body-read initially – bytes from cache on subsequent .any({ to: 'bytes', memo: true }) calls
1456
+ ```
1457
+
1458
+ #### The `.copy()` Static Method
1459
+
1460
+ **APIs**: `RequestPlus.copy()`
1461
+
1462
+ The `.copy()` static method is a convenience method for copying request instance properties as plain JSON object. This is useful for creating full or partial look-alike request instances – which the `.clone()` method doesn't directly reflect.
1463
+ `.copy()` takes an existing request instance and returns its properties:
1464
+
1465
+ ```js
1466
+ const requestInit = await RequestPlus.copy(request);
1467
+ // {
1468
+ // url,
1469
+ // method,
1470
+ // body,
1471
+ // headers,
1472
+ // mode,
1473
+ // credentials,
1474
+ // cache,
1475
+ // redirect,
1476
+ // referrer,
1477
+ // integrity
1478
+ // }
1479
+ ```
1480
+
1481
+ It also accepts an optional `overrides` object that provides overrides for specific properties:
1482
+
1483
+ ```js
1484
+ const requestInit = await RequestPlus.copy(request, { method: 'POST' });
1485
+ ```
1486
+
1487
+ The following transformation is applied:
1488
+
1489
+ + The `body` attribute is `null` for `method` = `GET` | `HEAD`.
1490
+ + For `body` overrides (via `overrides.body`), any `Content-Type` and `Content-Length` headers from the base instance are not inherited.
1491
+ + `mode: "navigate"` is automatically rewritten to `mode: "cors"`.
1492
+
1493
+ **Signature**:
1494
+
1495
+ + `.copy(request, overrides?)`: `Promise<object>`
1496
+
1497
+ **Example 1: _Create partial look-alike requests_**
1498
+
1499
+ Create a request instance from an existing instance with specific overrides.
1500
+
1501
+ ```js
1502
+ const request1 = new Request(url, {
1503
+ method: 'POST',
1504
+ body: JSON.stringify(jsonObject),
1505
+ headers: { 'Content-Type': 'application/json' }
1506
+ });
1507
+ ```
1508
+
1509
+ ```js
1510
+ const { url, ...requestInit } = await RequestPlus.copy(request1, { method: 'GET' });
1511
+ const request2 = new Request(url, requestInit);
1512
+
1513
+ console.log(request2.method); // GET
1514
+ console.log(request2.body); // null
1515
+ ```
1516
+
1517
+ ### Structured HTTP Headers – `HeadersPlus`
1518
+
1519
+ `HeadersPlus` is an extension of the `Headers` interface that adds support for structured input and output values on common HTTP headers:
1520
+
1521
+ + The `Cookie` Request Header
1522
+ + The `Set-Cookie` Response Header
1523
+ + The `Range` Request Header
1524
+ + The `Content-Range` Response Header
1525
+ + The `Accept` Request Header
1526
+
1527
+ `HeadersPlus` is the _Headers_ interface exposed by `RequestPlus` and `ResponsePlus`:
1528
+
1529
+ ```js
1530
+ const request = new RequestPlus();
1531
+ console.log(request.headers); // HeadersPlus
1532
+ ```
1533
+
1534
+ It can also be directly instantiated:
1535
+
1536
+ ```js
1537
+ import { HeadersPlus } from 'fetch-plus';
1538
+
1539
+ const headers = new HeadersPlus({ 'Content-Type': 'text/plain' });
1540
+ headers.set('Content-Type', 'text/html');
1541
+ ```
1542
+
1543
+ #### The `Cookie` Request Header
1544
+
1545
+ **Structured output**: Get the `Cookie` header as a structured array of objects.
1546
+
1547
+ ```js
1548
+ // Syntax
1549
+ const cookies = headers.get('Cookie', true);
1550
+ ```
1551
+
1552
+ ```js
1553
+ // Example
1554
+ const cookies = headers.get('Cookie', true);
1555
+ // [
1556
+ // { name: 'session', value: 'abc123' },
1557
+ // { name: 'theme', value: 'dark' },
1558
+ // { name: 'lang', value: 'en-US' }
1559
+ // ]
1560
+ ```
1561
+
1562
+ **The default**: Get as raw strings.
1563
+
1564
+ ```js
1565
+ const cookies = headers.get('Cookie');
1566
+ // 'session=abc123; theme=dark; lang=en-US'
1567
+ ```
1568
+
1569
+ **Structured input**: Set the `Cookie` header from a structured object or array of objects.
1570
+
1571
+ ```js
1572
+ // Syntax
1573
+ const cookie = { name, value };
1574
+
1575
+ headers.set('Cookie', cookie);
1576
+ headers.set('Cookie', [cookie, ...]);
1577
+ ```
1578
+
1579
+ ```js
1580
+ // Example
1581
+ headers.set('Cookie', { name: 'session', value: 'xyz789' });
1582
+ // Serializes to:
1583
+ // 'session=xyz789'
1584
+ ```
1585
+
1586
+ **The default**: Set as raw strings.
1587
+
1588
+ ```js
1589
+ headers.set('Cookie', 'session=xyz789');
1590
+ ```
1591
+
1592
+ #### The `Set-Cookie` Response Header
1593
+
1594
+ **Structured output**: Get the `Set-Cookie` header as a structured array of objects.
1595
+
1596
+ ```js
1597
+ // Syntax
1598
+ const cookies = headers.get('Set-Cookie', true);
1599
+ ```
1600
+
1601
+ ```js
1602
+ // Example
1603
+ const cookies = headers.get('Set-Cookie', true);
1604
+ // [
1605
+ // { name: 'session', value: 'xyz789', secure: true, path: '/' },
1606
+ // { name: 'prefs', value: 'dark_mode', maxAge: 3600 }
1607
+ // ]
1608
+ ```
1609
+
1610
+ **The default**: Get as raw strings.
1611
+
1612
+ ```js
1613
+ const cookies = headers.get('Set-Cookie');
1614
+ // 'session=xyz789; Secure; Path=/'
1615
+
1616
+ const cookies = headers.getSetCookie();
1617
+ // ['session=xyz789; Secure; Path=/', 'prefs=dark_mode; Max-Age=3600']
1618
+ ```
1619
+
1620
+ **Structured input**: Set the `Set-Cookie` header using a structured object or array of objects.
1621
+
1622
+ ```js
1623
+ // Syntax
1624
+ const cookie = { name, value, secure?, path?, expires?, maxAge?, httpOnly?, sameSite? };
1625
+
1626
+ headers.set('Set-Cookie', cookie);
1627
+ headers.set('Set-Cookie', [cookie, ...]);
1628
+ headers.append('Set-Cookie', cookie);
1629
+ ```
1630
+
1631
+ ```js
1632
+ // Example
1633
+ headers.append('Set-Cookie', {
1634
+ name: 'session',
1635
+ value: 'xyz789',
1636
+ secure: true,
1637
+ httpOnly: true,
1638
+ sameSite: 'strict'
1639
+ });
1640
+ // Serializes to:
1641
+ // 'session=xyz789; Secure; HttpOnly; SameSite=strict'
1642
+ ```
1643
+
1644
+ ```js
1645
+ // Example (multiple)
1646
+ headers.set('Set-Cookie', [
1647
+ { name: 'session', value: 'xyz789', secure: true, httpOnly: true, sameSite: 'strict' },
1648
+ { name: 'prefs', value: 'dark_mode', maxAge: 3600 }
1649
+ ]);
1650
+ // Translates to:
1651
+ // append('Set-Cookie', 'session=xyz789; Secure; HttpOnly; SameSite=strict')
1652
+ // append('Set-Cookie', 'prefs=dark_mode; Max-Age=3600')
1653
+ ```
1654
+
1655
+ **The default**: Set as raw strings.
1656
+
1657
+ ```js
1658
+ headers.append('Set-Cookie', 'session=xyz789; Secure; HttpOnly; SameSite=strict');
1659
+ ```
1660
+
1661
+ #### The `Range` Request Header
1662
+
1663
+ **Structured output**: Get the `Range` header as a structured array of range arrays, complete with helper methods.
1664
+
1665
+ ```js
1666
+ // Syntax
1667
+ const ranges = headers.get('Range', true);
1668
+ ```
1669
+
1670
+ ```js
1671
+ // Example
1672
+ const ranges = headers.get('Range', true);
1673
+ // [
1674
+ // [0, 500],
1675
+ // [1000, 1500]
1676
+ // ]
1677
+
1678
+ // toString
1679
+ ranges[0].toString(); // '0-500'
1680
+ ranges[1].toString(); // '1000-1500'
1681
+
1682
+ // Compute against concrete resource length
1683
+ const resourceLength = 1200;
1684
+
1685
+ ranges[0].canResolveAgainst(0/*start*/, resourceLength/*total*/); // true
1686
+ ranges[0].resolveAgainst(resourceLength); // [0, 499]
1687
+
1688
+ ranges[1].canResolveAgainst(0/*start*/, resourceLength/*total*/); // false
1689
+ ranges[1].resolveAgainst(resourceLength); // [1000, 1199]
1690
+ ```
1691
+
1692
+ ...with nulls:
1693
+
1694
+ ```js
1695
+ // Example
1696
+ const ranges = headers.get('Range', true);
1697
+ // [
1698
+ // [0, null],
1699
+ // [null, 1500]
1700
+ // ]
1701
+
1702
+ // toString
1703
+ ranges[0].toString(); // '0-'
1704
+ ranges[1].toString(); // '-1500'
1705
+
1706
+ // Compute against concrete resource length
1707
+ const resourceLength = 1200;
1708
+
1709
+ ranges[0].canResolveAgainst(0/*start*/, resourceLength/*total*/); // true
1710
+ ranges[0].resolveAgainst(resourceLength); // [0, 1199]
1711
+
1712
+ ranges[1].canResolveAgainst(0/*start*/, resourceLength/*total*/); // false
1713
+ ranges[1].resolveAgainst(resourceLength); // [0, 1199]
1714
+ ```
1715
+
1716
+ **The default**: Get as raw strings.
1717
+
1718
+ ```js
1719
+ const ranges = headers.get('Range');
1720
+ // 'bytes=0-500, 1000-1500'
1721
+ ```
1722
+
1723
+ ...with nulls:
1724
+
1725
+ ```js
1726
+ const ranges = headers.get('Range');
1727
+ // 'bytes=0-, -1500'
1728
+ ```
1729
+
1730
+ **Structured input**: Set the `Range` header using an array of ranges (strings or arrays).
1731
+
1732
+ ```js
1733
+ // Syntax
1734
+ const arraySyntax = [ [start?, end?], ... ];
1735
+ const stringSyntax = [ '<start>-<end>', ... ];
1736
+
1737
+ headers.set('Range', arraySyntax);
1738
+ headers.set('Range', stringSyntax);
1739
+ ```
1740
+
1741
+ ```js
1742
+ // Example
1743
+ headers.set('Range', [[0, 500], [1000, 1500]]);
1744
+ // Serializes to: 'bytes=0-500, 1000-1500'
1745
+
1746
+ // ...with nulls
1747
+ headers.set('Range', [[0, null], [null, 1500]]);
1748
+ // Serializes to: 'bytes=0-, -1500'
1749
+ ```
1750
+
1751
+ ```js
1752
+ // Example (alt)
1753
+ headers.set('Range', ['0-500', '1000-1500']);
1754
+ // Serializes to: 'bytes=0-500, 1000-1500'
1755
+ ```
1756
+
1757
+ **The default**: Set as raw strings.
1758
+
1759
+ ```js
1760
+ headers.set('Range', 'bytes=0-500, 1000-1500');
1761
+ ```
1762
+
1763
+ #### The `Content-Range` Response Header
1764
+
1765
+ **Structured output**: Get the `Content-Range` header as a structured array.
1766
+
1767
+ ```js
1768
+ // Syntax
1769
+ const contentRange = headers.get('Content-Range', true);
1770
+ ```
1771
+
1772
+ ```js
1773
+ // Example
1774
+ headers.get('Content-Range', true);
1775
+ // ['0-499', '1234']
1776
+ ```
1777
+
1778
+ **The default**: Get as a raw string.
1779
+
1780
+ ```js
1781
+ headers.get('Content-Range');
1782
+ // 'bytes 0-499/1234'
1783
+ ```
1784
+
1785
+ **Structured input**: Set the `Content-Range` header using a structured array.
1786
+
1787
+ ```js
1788
+ // Syntax
1789
+ headers.set('Content-Range', ['<start>-<end>', '<total>']);
1790
+ ```
1791
+
1792
+ ```js
1793
+ // Example
1794
+ headers.set('Content-Range', ['0-499', '1234']);
1795
+ // Serializes to:
1796
+ // 'bytes 0-499/1234'
1797
+ ```
1798
+
1799
+ > If the structured input does not match the required shape, an error is thrown.
1800
+
1801
+ **The default**: Set as a raw string.
1802
+
1803
+ ```js
1804
+ headers.set('Content-Range', 'bytes 0-499/1234');
1805
+ ```
1806
+
1807
+ #### The `Accept` Request Header
1808
+
1809
+ **Structured output**: Get the `Accept` header as a specialized object for content negotiation.
1810
+
1811
+ ```js
1812
+ // Syntax
1813
+ const accept = headers.get('Accept', true);
1814
+ ```
1815
+
1816
+ ```js
1817
+ // Example
1818
+ const accept = headers.get('Accept', true);
1819
+ // [
1820
+ // [ 'text/html', 1 ],
1821
+ // [ 'application/json', 0.9 ],
1822
+ // [ 'image/*', 0.8 ]
1823
+ // ]
1824
+
1825
+ // toString
1826
+ accept.toString(); // 'text/html,application/json;q=0.9,image/*;q=0.8'
1827
+
1828
+ // Check priority with match()
1829
+ accept.match('text/html'); // 1.0
1830
+ accept.match('application/json'); // 0.9
1831
+ accept.match('image/webp'); // 1.8 (matching image/*)
1832
+ accept.match('image/svg+xml'); // 0 (not found is 0)
1833
+ ```
1834
+
1835
+ **The default**: Get as raw strings.
1836
+
1837
+ ```js
1838
+ headers.get('Accept'); // 'text/html,application/json;q=0.9,image/*;q=0.8'
1839
+ ```
1840
+
1841
+ **Structured input**: Set the `Accept` header using an array of MIME types.
1842
+
1843
+ ```js
1844
+ // Syntax
1845
+ const arraySyntax = [ [mime, q?], ... ];
1846
+ const stringSyntax = [ '<mime>;q=<q>', ... ];
1847
+
1848
+ headers.set('Accept', arraySyntax);
1849
+ headers.set('Accept', stringSyntax);
1850
+ ```
1851
+
1852
+ ```js
1853
+ // Example
1854
+ headers.set('Accept', [
1855
+ ['text/html', 1],
1856
+ ['application/json', 0.9],
1857
+ ['image/*', 0.8]
1858
+ ]);
1859
+ // Serializes to: 'text/html,application/json;q=0.9,image/*;q=0.8'
1860
+ ```
1861
+
1862
+ **The default**: Set as raw strings.
1863
+
1864
+ ```js
1865
+ headers.set('Accept', 'text/html,application/json;q=0.9,image/*;q=0.8');
1866
+ ```
1867
+
1868
+ ### JSON-Native FormData Interface – `FormDataPlus`
1869
+
1870
+ `FormDataPlus` is an extension of the `FormData` interface that adds support for a JSON output method and a JSON factory method:
1871
+
1872
+ ```js
1873
+ // Format to JSON
1874
+ const json = await formData.json();
1875
+ ```
1876
+
1877
+ ```js
1878
+ // Create an instance from JSON
1879
+ const formData = FormDataPlus.json(json);
1880
+ ```
1881
+
1882
+ This makes `FormData` pair nicely with sibling interfaces like `Response` that already work this way:
1883
+
1884
+ ```js
1885
+ // Read as JSON
1886
+ const json = await response.json();
1887
+ ```
1888
+
1889
+ ```js
1890
+ // Create an instance from JSON
1891
+ const response = Response.json(json);
1892
+ ```
1893
+
1894
+ `FormDataPlus` is the _FormData_ interface exposed by `RequestPlus#formData()` and `ResponsePlus#formData()`:
1895
+
1896
+ ```js
1897
+ const request = new RequestPlus();
1898
+ console.log(await request.formData()); // FormDataPlus
1899
+ ```
1900
+
1901
+ It can also be directly instantiated:
1902
+
1903
+ ```js
1904
+ import { FormDataPlus } from 'fetch-plus';
1905
+
1906
+ const formData = new FormDataPlus();
1907
+ formData.set('key', 'value');
1908
+ ```
1909
+
1910
+ #### The `.json()` Instance Method
1911
+
1912
+ The `.json()` method is an output method that returns the instance as a JSON object.
1913
+
1914
+ **Signature**:
1915
+
1916
+ + `.json()`: `Promise<object>`
1917
+ + `.json({ decodeLiterals?, meta? })`: `Promise<object>`
1918
+
1919
+ **Options**:
1920
+
1921
+ + `decodeLiterals`: `boolean` Controls whether JSON primitives (`null`, `true`, `false`) originally encoded as `Blobs` in the instance are decoded back to their literal JSON value. Defaults to `true`.
1922
+ + `meta`: `boolean` Controls whether conversion-specific metadata are added to the returned structure. When true, the result is returned in a `{ result, ...meta }` structure – with `result` being the actual JSON result and `...meta` being metadata about the result.
1923
+
1924
+ + `result`: `object`. The actual JSON result.
1925
+ + `isDirectlySerializable`: `boolean`. This is true if the returned JSON is directly serializable to a JSON string – implying that there are no compound data types, like `Blobs`, in the structure. It is false otherwise.
1926
+
1927
+ **Example 1: _Direct JSON representation_**
1928
+
1929
+ Call `json()` and get back a corresponding JSON representation of the instance. Note that bracket key notations produce equivalent depth in the resulting JSON tree.
1930
+
1931
+ ```js
1932
+ const formData = new FormDataPlus();
1933
+
1934
+ formData.append('name', 'Alice');
1935
+ formData.append('age', '30');
1936
+ formData.append('skills[]', 'JS');
1937
+ formData.append('skills[]', 'Testing');
1938
+
1939
+ const json = await formData.json();
1940
+
1941
+ console.log(json);
1942
+ // {
1943
+ // name: 'Alice',
1944
+ // age: 30,
1945
+ // skills: ['JS', 'Testing']
1946
+ // }
1947
+ ```
1948
+
1949
+ **Example 2: _Handle special data types_**
1950
+
1951
+ FormData has no concept of JSON primitives (`null`, `true`, `false`). Encode them specially for lossless conversion.
1952
+
1953
+ ```js
1954
+ const formData = new FormDataPlus();
1955
+
1956
+ formData.append('name', 'Alice');
1957
+ formData.append('age', '30');
1958
+ formData.append('prefers_reduced_motion', new Blob(['true'], { type: 'application/json' }));
1959
+ formData.append('avatar', new Blob([imageBytes], { type: 'image/png' }));
1960
+ formData.append('skills[primary][]', 'JS');
1961
+ formData.append('skills[primary][]', 'Testing');
1962
+
1963
+ const { result: json, isDirectlySerializable } = await formData.json({
1964
+ decodeLiterals: true/* the default */,
1965
+ meta: true,
1966
+ });
1967
+
1968
+ console.log(json);
1969
+ // {
1970
+ // name: 'Alice',
1971
+ // age: 30,
1972
+ // prefers_reduced_motion: true,
1973
+ // avatar: Blob,
1974
+ // skills: { primary: ['JS', 'Testing'] }
1975
+ // }
1976
+
1977
+ console.log(isDirectlySerializable); // false
1978
+ // Has avatar: Blob
1979
+ ```
1980
+
1981
+
1982
+ #### The `.json()` Static Method
1983
+
1984
+ The `.json()` static method is a factory method for creating new FormData instances directly from JSON objects.
1985
+
1986
+ **Signature**:
1987
+
1988
+ + `FormDataPlus.json(json)`: `FormDataPlus`
1989
+ + `FormDataPlus.json(json, { encodeLiterals?, meta? })`: `FormDataPlus`
1990
+
1991
+ **Options**:
1992
+
1993
+ + `encodeLiterals`: `boolean` Controls whether JSON primitives (`null`, `true`, `false`) are encoded as `Blobs` in the instance to preserve their meaning. Defaults to `true`.
1994
+ + `meta`: `boolean` Controls whether conversion-specific metadata are added to the returned structure. When true, the result is returned in a `{ result, ...meta }` structure – with `result` being the actual `FormData` instance and `...meta` being metadata about the result.
1995
+
1996
+ + `result`: `FormData`. The resulting `FormData` instance.
1997
+ + `isDirectlySerializable`: `boolean`. This is true if the input JSON is directly serializable to a JSON string – implying that there are no compound data types, like `Blobs`, in the structure. It is false otherwise.
1998
+
1999
+ **Example 1: _Direct JSON conversion_**
2000
+
2001
+ Call `json()` and get back a corresponding `FormData` representation of the JSON structure. Note that depth is modelled in bracket key notations.
2002
+
2003
+ ```js
2004
+ const json = {
2005
+ name: 'Alice',
2006
+ age: 30,
2007
+ skills: ['JS', 'Testing']
2008
+ };
2009
+
2010
+ const formData = FormDataPlus.json(json);
2011
+
2012
+ console.log([...formData.keys()]);
2013
+ // ['name', 'age', 'skills[0]', 'skills[1]']
2014
+ ```
2015
+
2016
+ **Example 2: _Handle special data types_**
2017
+
2018
+ FormData has no concept of JSON primitives (`null`, `true`, `false`). FormDataPlus automatically encodes them by default for lossless conversion.
2019
+
2020
+ ```js
2021
+ const json = {
2022
+ name: 'Alice',
2023
+ age: 30,
2024
+ prefers_reduced_motion: true,
2025
+ avatar: new Blob([imageBytes], { type: 'image/png' }),
2026
+ skills: { primary: ['JS', 'Testing'] }
2027
+ };
2028
+
2029
+ const { result: formData, isDirectlySerializable } = FormDataPlus.json(json, {
2030
+ encodeLiterals: true/* the default */,
2031
+ meta: true,
2032
+ });
2033
+
2034
+ console.log(formData.get('prefers_reduced_motion')); // Blob
2035
+ console.log(formData.get('skills[primary][1]')); // "Testing"
2036
+
2037
+ console.log(isDirectlySerializable); // false
2038
+ // Has avatar: Blob
2039
+ ```
2040
+
2041
+ ---
2042
+
2043
+ ## License
2044
+
2045
+ MIT
2046
+
2047
+ [npm-version-src]: https://img.shields.io/npm/v/@webqit/fetch-plus?style=flat&colorA=18181B&colorB=F0DB4F
2048
+ [npm-version-href]: https://npmjs.com/package/@webqit/fetch-plus
2049
+ [bundle-src]: https://img.shields.io/bundlephobia/minzip/@webqit/fetch-plus?style=flat&colorA=18181B&colorB=F0DB4F
2050
+ [bundle-href]: https://bundlephobia.com/result?p=@webqit/fetch-plus
2051
+ [license-src]: https://img.shields.io/github/license/webqit/fetch-plus.svg?style=flat&colorA=18181B&colorB=F0DB4F