@webqit/fetch-plus 0.1.2 → 0.1.3
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 +2050 -2
- package/dist/main.js +1 -1
- package/dist/main.js.map +4 -4
- package/package.json +8 -8
- package/src/FormDataPlus.js +23 -15
- package/src/HeadersPlus.js +128 -56
- package/src/LiveResponse.js +233 -155
- package/src/RequestPlus.js +9 -7
- package/src/ResponsePlus.js +6 -6
- package/src/index.js +0 -1
- package/src/messageParserMixin.js +217 -0
- package/test/1.basic.test.js +314 -0
- package/test/2.LiveResponse.test.js +261 -0
- package/test/3.LiveResponse.integration.test.js +459 -0
- package/src/URLSearchParamsPlus.js +0 -80
- package/src/core.js +0 -172
- package/test/basic.test.js +0 -0
package/README.md
CHANGED
|
@@ -1,3 +1,2051 @@
|
|
|
1
|
-
# Fetch+
|
|
1
|
+
# Fetch+ – _Advanced HTTP for the Modern Web_
|
|
2
2
|
|
|
3
|
-
|
|
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 `3 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
|