@webqit/fetch-plus 0.1.1

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.
@@ -0,0 +1,494 @@
1
+ import { _isObject, _isTypeObject } from '@webqit/util/js/index.js';
2
+ import { BroadcastChannelPlus, WebSocketPort, MessagePortPlus, Observer } from '@webqit/port-plus';
3
+ import { isTypeStream, _meta, _wq } from './core.js';
4
+ import { ResponsePlus } from './ResponsePlus.js';
5
+ import { HeadersPlus } from './HeadersPlus.js';
6
+
7
+ export class LiveResponse extends EventTarget {
8
+
9
+ static get xHeaderName() {
10
+ return 'X-Background-Messaging-Port';
11
+ }
12
+
13
+ static test(unknown) {
14
+ if (unknown instanceof LiveResponse
15
+ || unknown?.[Symbol.toStringTag] === 'LiveResponse') {
16
+ return 'LiveResponse';
17
+ }
18
+ if (unknown?.[Symbol.toStringTag] === 'LiveProgramHandle') {
19
+ return 'LiveProgramHandle';
20
+ }
21
+ if (unknown instanceof Response) {
22
+ return 'Response';
23
+ }
24
+ if (isGenerator(unknown)) {
25
+ return 'Generator';
26
+ }
27
+ return 'Default';
28
+ }
29
+
30
+ static hasBackgroundPort(respone) {
31
+ return !!respone.headers?.get?.(this.xHeaderName)?.trim();
32
+ }
33
+
34
+ static getBackgroundPort(respone) {
35
+ if (!/Response/.test(this.test(respone))) {
36
+ return;
37
+ }
38
+ const responseMeta = _meta(respone);
39
+
40
+ if (!responseMeta.has('background_port')) {
41
+ const value = respone.headers.get(this.xHeaderName)?.trim();
42
+ if (!value) return;
43
+
44
+ const [proto, portID] = value.split(':');
45
+ if (!['ws', 'br'].includes(proto)) {
46
+ throw new Error(`Unknown background messaging protocol: ${value}`);
47
+ }
48
+
49
+ const backgroundPort = proto === 'br'
50
+ ? new BroadcastChannelPlus(portID, { autoStart: false, postAwaitsOpen: true, clientServerMode: 'client' })
51
+ : new WebSocketPort(portID, { autoStart: false, naturalOpen: false, postAwaitsOpen: true });
52
+
53
+ responseMeta.set('background_port', backgroundPort);
54
+ }
55
+
56
+ return responseMeta.get('background_port');
57
+ }
58
+
59
+ static from(data, ...args) {
60
+ if (this.test(data) === 'LiveResponse') {
61
+ return data.clone(...args);
62
+ }
63
+ return new this(data, ...args);
64
+ }
65
+
66
+ /* INSTANCE */
67
+
68
+ [Symbol.toStringTag] = 'LiveResponse';
69
+
70
+ constructor(body, ...args) {
71
+ super();
72
+ this.#replaceWith(body, ...args);
73
+ }
74
+
75
+ /* Level 1 props */
76
+
77
+ #body = null;
78
+ get body() { return this.#body; }
79
+
80
+ get bodyUsed() { return false; }
81
+
82
+ #headers = new HeadersPlus;
83
+ get headers() { return this.#headers; }
84
+
85
+ #status = 200;
86
+ get status() { return this.#status; }
87
+
88
+ #statusText = '';
89
+ get statusText() { return this.#statusText; }
90
+
91
+ /* Level 2 props */
92
+
93
+ #type = 'basic';
94
+ get type() { return this.#type; }
95
+
96
+ #redirected = false;
97
+ get redirected() { return this.#redirected; }
98
+
99
+ #url = null;
100
+ get url() { return this.#url; }
101
+
102
+ get ok() { return this.#status >= 200 && this.#status < 299; }
103
+
104
+ async arrayBuffer() { throw new Error(`LiveResponse does not support the arrayBuffer() method.`); }
105
+
106
+ async formData() { throw new Error(`LiveResponse does not support the formData() method.`); }
107
+
108
+ async json() { throw new Error(`LiveResponse does not support the json() method.`); }
109
+
110
+ async text() { throw new Error(`LiveResponse does not support the text() method.`); }
111
+
112
+ async blob() { throw new Error(`LiveResponse does not support the blob() method.`); }
113
+
114
+ async bytes() { throw new Error(`LiveResponse does not support the bytes() method.`); }
115
+
116
+ /* Level 3 props */
117
+
118
+ get background() { return this.constructor.getBackgroundPort(this); }
119
+
120
+ // Lifecycle
121
+
122
+ #abortController = new AbortController;
123
+ get signal() { return this.#abortController.signal; }
124
+
125
+ get readyState() {
126
+ const readyStateInternals = getReadyStateInternals.call(this);
127
+ return readyStateInternals.done.state ? 'done'
128
+ : (readyStateInternals.live.state ? 'live' : 'waiting');
129
+ }
130
+
131
+ readyStateChange(query) {
132
+ if (!['live', 'done'].includes(query)) {
133
+ throw new Error(`Invalid readyState query "${query}"`);
134
+ }
135
+ const readyStateInternals = getReadyStateInternals.call(this);
136
+ return readyStateInternals[query].promise;
137
+ }
138
+
139
+ disconnect() {
140
+ this.#abortController.abort();
141
+ this.#abortController = new AbortController;
142
+ }
143
+
144
+ #currentFramePromise;
145
+ #extendLifecycle(promise) {
146
+ const readyStateInternals = getReadyStateInternals.call(this);
147
+ if (readyStateInternals.done.state) {
148
+ throw new Error('Response already done.');
149
+ }
150
+ this.#currentFramePromise = promise;
151
+ promise.then((value) => {
152
+ if (this.#currentFramePromise === promise) {
153
+ this.#currentFramePromise = null;
154
+ readyStateInternals.done.state = true;
155
+ readyStateInternals.done.resolve(value);
156
+ }
157
+ }).catch((e) => {
158
+ if (this.#currentFramePromise === promise) {
159
+ this.#currentFramePromise = null;
160
+ readyStateInternals.done.state = true;
161
+ readyStateInternals.done.reject(e);
162
+ }
163
+ });
164
+ }
165
+
166
+ async replaceWith(body, ...args) {
167
+ if (this.readyState === 'done') {
168
+ throw new Error('Response already done.');
169
+ }
170
+ this.disconnect(); // Disconnect from existing source if any
171
+ await this.#replaceWith(body, ...args);
172
+ }
173
+
174
+ async #replaceWith(body, ...args) {
175
+ if (body instanceof Promise) {
176
+ this.#extendLifecycle(body);
177
+ return await new Promise((resolve, reject) => {
178
+ let aborted = false;
179
+ this.#abortController.signal.addEventListener('abort', () => {
180
+ aborted = true
181
+ resolve();
182
+ });
183
+ body.then(async (resolveData) => {
184
+ if (aborted) return;
185
+ await this.#replaceWith(resolveData, ...args);
186
+ resolve();
187
+ });
188
+ body.catch((e) => reject(e));
189
+ });
190
+ }
191
+
192
+ // ----------- Formatters
193
+
194
+ const directReplaceWith = (responseLike) => {
195
+ const $body = responseLike.body;
196
+
197
+ this.#status = responseLike.status;
198
+ this.#statusText = responseLike.statusText;
199
+
200
+ for (const [name] of [/*IMPORTANT*/...this.#headers.entries()]) { // for some reason, some entries not produced when not spread
201
+ this.#headers.delete(name);
202
+ }
203
+ for (const [name, value] of responseLike.headers.entries()) {
204
+ this.#headers.append(name, value);
205
+ }
206
+
207
+ this.#type = responseLike.type;
208
+ this.#redirected = responseLike.redirected;
209
+ this.#url = responseLike.url;
210
+
211
+ this.#body = $body;
212
+
213
+ // Must come after all property assignments above because it fires events
214
+ Observer.defineProperty(this, 'body', { get: () => this.#body, enumerable: true, configurable: true });
215
+
216
+ const readyStateInternals = getReadyStateInternals.call(this);
217
+ readyStateInternals.live.state = true;
218
+ readyStateInternals.live.resolve();
219
+
220
+ this.dispatchEvent(new Event('replace'));
221
+ };
222
+
223
+ const wrapReplaceWith = async (body, options) => {
224
+ directReplaceWith({
225
+ body,
226
+ status: 200,
227
+ statusText: '',
228
+ headers: new Headers,
229
+ ...options,
230
+ type: 'basic',
231
+ redirected: false,
232
+ url: null
233
+ });
234
+ };
235
+
236
+ // ----------- "Response" handler
237
+
238
+ const execReplaceWithResponse = async (response, options) => {
239
+ let body, jsonSuccess = false;
240
+ try {
241
+ body = response instanceof Response
242
+ ? await ResponsePlus.prototype.parse.call(response, { to: 'json' })
243
+ : response.body;
244
+ jsonSuccess = true;
245
+ } catch (e) {
246
+ body = response.body;
247
+ }
248
+ directReplaceWith({
249
+ body,
250
+ status: response.status,
251
+ statusText: response.statusText,
252
+ headers: response.headers,
253
+ ...options,
254
+ type: response.type,
255
+ redirected: response.redirected,
256
+ url: response.url,
257
+ });
258
+
259
+ if (this.constructor.test(response) === 'LiveResponse') {
260
+ response.addEventListener('replace', () => {
261
+ directReplaceWith(response)
262
+ }, { signal: this.#abortController.signal });
263
+ return await response.readyStateChange('done');
264
+ }
265
+
266
+ if (this.hasBackgroundPort(response)) {
267
+ const backgroundPort = this.constructor.getBackgroundPort(response);
268
+ // Bind to upstream mutations
269
+ let undoInitialProjectMutations;
270
+ if (jsonSuccess) {
271
+ undoInitialProjectMutations = donePromise.projectMutations({
272
+ from: 'initial_response',
273
+ to: body,
274
+ signal: this.#abortController.signal
275
+ });
276
+ }
277
+ // Bind to replacements
278
+ backgroundPort.addEventListener('response.replace', (e) => {
279
+ undoInitialProjectMutations?.();
280
+ undoInitialProjectMutations = null;
281
+
282
+ directReplaceWith(e.data);
283
+ }, { signal: this.#abortController.signal });
284
+ // Wait until done
285
+ return await backgroundPort.readyStateChange('close');
286
+ }
287
+
288
+ return Promise.resolve();
289
+ };
290
+
291
+ // ----------- "Generator" handler
292
+
293
+ const execReplaceWithGenerator = async (gen, options) => {
294
+ const firstFrame = await gen.next();
295
+ const firstValue = await firstFrame.value;
296
+
297
+ await this.#replaceWith(firstValue, { done: firstFrame.done, ...options });
298
+ // this is the first time options has a chance to be applied
299
+
300
+ let frame = firstFrame;
301
+ let value = firstValue;
302
+
303
+ while (!frame.done && !this.#abortController.signal.aborted) {
304
+ frame = await gen.next();
305
+ value = await frame.value;
306
+ if (!this.#abortController.signal.aborted) {
307
+ await this.#replaceWith(value, { done: options.done === false ? false : frame.done });
308
+ // top-level false need to be respected: means keep instance alive even when done
309
+ }
310
+ }
311
+ };
312
+
313
+ // ----------- "LiveProgramHandle" handler
314
+
315
+ const execReplaceWithLiveProgramHandle = async (liveProgramHandle, options) => {
316
+ await this.#replaceWith(liveProgramHandle.value, options);
317
+ // this is the first time options has a chance to be applied
318
+
319
+ Observer.observe(
320
+ liveProgramHandle,
321
+ 'value',
322
+ (e) => this.#replaceWith(e.value, { done: false }),
323
+ // we're never done unless explicitly aborted
324
+ { signal: this.#abortController.signal }
325
+ );
326
+
327
+ return new Promise(() => { });
328
+ };
329
+
330
+ // ----------- Procesing time
331
+
332
+ const options = _isObject(args[0]/* !ORDER 1 */) ? { ...args.shift() } : {};
333
+ const frameClosure = typeof args[0]/* !ORDER 2 */ === 'function' ? args.shift() : null;
334
+
335
+ if ('status' in options) {
336
+ options.status = parseInt(options.status);
337
+ if (options.status < 200 || options.status > 599) {
338
+ throw new Error(`The status provided (${options.status}) is outside the range [200, 599].`);
339
+ }
340
+ }
341
+ if ('statusText' in options) {
342
+ options.statusText = String(options.statusText);
343
+ }
344
+ if (options.headers && !(options.headers instanceof Headers)) {
345
+ options.headers = new Headers(options.headers);
346
+ }
347
+
348
+ // ----------- Dispatch time
349
+
350
+ let donePromise;
351
+
352
+ if (/Response/.test(this.constructor.test(body))) {
353
+ if (frameClosure) {
354
+ throw new Error(`frameClosure is not supported for responses.`);
355
+ }
356
+ donePromise = await execReplaceWithResponse(body, options);
357
+ } else if (this.constructor.test(body) === 'Generator') {
358
+ if (frameClosure) {
359
+ throw new Error(`frameClosure is not supported for generators.`);
360
+ }
361
+ donePromise = await execReplaceWithGenerator(body, options);
362
+ } else if (this.constructor.test(body) === 'LiveProgramHandle') {
363
+ if (frameClosure) {
364
+ throw new Error(`frameClosure is not supported for live program handles.`);
365
+ }
366
+ donePromise = await execReplaceWithLiveProgramHandle(body, options);
367
+ } else {
368
+ donePromise = wrapReplaceWith(body, options);
369
+ if (frameClosure) {
370
+ const reactiveProxy = _isTypeObject(body) && !isTypeStream(body)
371
+ ? Observer.proxy(body, { chainable: true, membrane: body })
372
+ : body;
373
+ donePromise = Promise.resolve(frameClosure.call(this, reactiveProxy));
374
+ }
375
+ }
376
+
377
+ // Lifecycle time
378
+
379
+ this.#extendLifecycle(options.done === false ? new Promise(() => { }) : donePromise);
380
+
381
+ return await new Promise((resolve, reject) => {
382
+ this.#abortController.signal.addEventListener('abort', resolve);
383
+ donePromise.then(() => resolve());
384
+ donePromise.catch((e) => reject(e));
385
+ });
386
+ }
387
+
388
+ // ----------- Conversions
389
+
390
+ toResponse({ client: clientPort, signal: abortSignal } = {}) {
391
+ if (clientPort && !(clientPort instanceof MessagePortPlus)) {
392
+ throw new Error('Client must be a MessagePortPlus interface');
393
+ }
394
+
395
+ const response = ResponsePlus.from(this.body, {
396
+ status: this.status,
397
+ statusText: this.statusText,
398
+ headers: this.headers,
399
+ });
400
+
401
+ const responseMeta = _meta(this);
402
+ _wq(response).set('meta', responseMeta);
403
+
404
+ if (clientPort && this.readyState === 'live') {
405
+ let undoInitialProjectMutations;
406
+ if (_isTypeObject(this.body) && !isTypeStream(this.body)) {
407
+ undoInitialProjectMutations = clientPort.projectMutations({
408
+ from: this.body,
409
+ to: 'initial_response',
410
+ signal: abortSignal/* stop observing mutations on body when we abort */
411
+ });
412
+ }
413
+
414
+ const replaceHandler = () => {
415
+ undoInitialProjectMutations?.();
416
+ undoInitialProjectMutations = null;
417
+
418
+ const headers = Object.fromEntries([...this.headers.entries()]);
419
+
420
+ if (headers?.['set-cookie']) {
421
+ delete headers['set-cookie'];
422
+ console.warn('Warning: The "set-cookie" header is not supported for security reasons and has been removed from the response.');
423
+ }
424
+
425
+ clientPort.postMessage({
426
+ body: this.body,
427
+ status: this.status,
428
+ statusText: this.statusText,
429
+ headers,
430
+ done: this.readyState === 'done',
431
+ }, { type: 'response.replace', live: true/*gracefully ignored if not an object*/, signal: this.#abortController.signal/* stop observing mutations on body a new body takes effect */ });
432
+ };
433
+
434
+ this.addEventListener('replace', replaceHandler, { signal: abortSignal/* stop listening when we abort */ });
435
+ }
436
+
437
+ return response;
438
+ }
439
+
440
+ async * toGenerator({ signal: abortSignal } = {}) {
441
+ do {
442
+ yield this.body;
443
+ } while (await new Promise((resolve) => {
444
+ this.addEventListener('replace', () => resolve(true), { once: true, signal: abortSignal });
445
+ this.readyStateChange('done').then(() => resolve(false));
446
+ }));
447
+ }
448
+
449
+ toLiveProgramHandle({ signal: abortSignal } = {}) {
450
+ const handle = new LiveProgramHandleX;
451
+
452
+ const replaceHandler = () => Observer.defineProperty(handle, 'value', { value: this.body, enumerable: true, configurable: true });
453
+ this.addEventListener('replace', replaceHandler, { signal: abortSignal });
454
+ replaceHandler();
455
+
456
+ return handle;
457
+ }
458
+
459
+ clone(init = {}) {
460
+ const clone = new this.constructor();
461
+
462
+ const responseMeta = _meta(this);
463
+ _wq(clone).set('meta', responseMeta);
464
+
465
+ clone.replaceWith(this, init);
466
+ return clone;
467
+ }
468
+ }
469
+
470
+ export const isGenerator = (obj) => {
471
+ return typeof obj?.next === 'function' &&
472
+ typeof obj?.throw === 'function' &&
473
+ typeof obj?.return === 'function';
474
+ };
475
+
476
+ export function getReadyStateInternals() {
477
+ const portPlusMeta = _meta(this);
478
+ if (!portPlusMeta.has('readystate_registry')) {
479
+ const $ref = (o) => {
480
+ o.promise = new Promise((res, rej) => (o.resolve = res, o.reject = rej));
481
+ return o;
482
+ };
483
+ portPlusMeta.set('readystate_registry', {
484
+ live: $ref({}),
485
+ done: $ref({}),
486
+ });
487
+ }
488
+ return portPlusMeta.get('readystate_registry');
489
+ }
490
+
491
+ class LiveProgramHandleX {
492
+ [Symbol.toStringTag] = 'LiveProgramHandle';
493
+ abort() { }
494
+ }
@@ -0,0 +1,80 @@
1
+ import { messageParserMixin, _meta, _wq } from './core.js';
2
+ import { HeadersPlus } from './HeadersPlus.js';
3
+
4
+ export class RequestPlus extends messageParserMixin(Request) {
5
+
6
+ constructor(url, init = {}) {
7
+ super(url, init);
8
+ HeadersPlus.upgradeInPlace(this.headers);
9
+ }
10
+
11
+ static upgradeInPlace(request) {
12
+ Object.setPrototypeOf(request, RequestPlus.prototype);
13
+ HeadersPlus.upgradeInPlace(request.headers);
14
+ }
15
+
16
+ static from(url, { memoize = false, ...init } = {}) {
17
+ if (url instanceof Request) return url;
18
+
19
+ let $type, $$body = init.body;
20
+ if ('body' in init) {
21
+ const { body, headers, $type: $$type } = super.from(init);
22
+ init = { ...init, body, headers };
23
+ $type = $$type;
24
+ }
25
+
26
+ const instance = new this.constructor(url, init);
27
+
28
+ if (memoize) {
29
+ const cache = _meta(instance, 'cache');
30
+ const typeMap = { json: 'json', FormData: 'formData', text: 'text', ArrayBuffer: 'arrayBuffer', Blob: 'blob', Bytes: 'bytes' };
31
+ cache.set(typeMap[$type] || 'original', $$body);
32
+ }
33
+
34
+ return instance;
35
+ }
36
+
37
+ static async copy(request, init = {}) {
38
+ const attrs = ['method', 'headers', 'mode', 'credentials', 'cache', 'redirect', 'referrer', 'integrity'];
39
+ const requestInit = attrs.reduce(($init, prop) => (
40
+ {
41
+ ...$init,
42
+ [prop]: prop in init
43
+ ? init[prop]
44
+ : (prop === 'headers'
45
+ ? new Headers(request[prop])
46
+ : request[prop])
47
+ }
48
+ ), {});
49
+ if (!['GET', 'HEAD'].includes(init.method?.toUpperCase() || request.method)) {
50
+ if ('body' in init) {
51
+ requestInit.body = init.body
52
+ if (!('headers' in init)) {
53
+ requestInit.headers.delete('Content-Type');
54
+ requestInit.headers.delete('Content-Length');
55
+ }
56
+ } else {
57
+ requestInit.body = await request.clone().arrayBuffer();
58
+ }
59
+ }
60
+ if (requestInit.mode === 'navigate') {
61
+ requestInit.mode = 'cors';
62
+ }
63
+ return { url: request.url, ...requestInit };
64
+ }
65
+
66
+ clone() {
67
+ const clone = super.clone();
68
+ RequestPlus.upgradeInPlace(clone);
69
+
70
+ const requestMeta = _meta(this);
71
+ _wq(clone).set('meta', new Map(requestMeta));
72
+ if (requestMeta.has('cache')) {
73
+ requestMeta.set('cache', new Map(requestMeta.get('cache')));
74
+ }
75
+
76
+ return clone;
77
+ }
78
+ }
79
+
80
+
@@ -0,0 +1,56 @@
1
+ import { messageParserMixin, _meta, _wq } from './core.js';
2
+ import { HeadersPlus } from './HeadersPlus.js';
3
+
4
+ export class ResponsePlus extends messageParserMixin(Response) {
5
+
6
+ constructor(body, init = {}) {
7
+ super(body, init);
8
+ HeadersPlus.upgradeInPlace(this.headers);
9
+ }
10
+
11
+ static upgradeInPlace(response) {
12
+ Object.setPrototypeOf(response, ResponsePlus.prototype);
13
+ HeadersPlus.upgradeInPlace(response.headers);
14
+ }
15
+
16
+ static from(body, { memoize = false, ...init } = {}) {
17
+ if (body instanceof Response) return body;
18
+
19
+ let $type, $body = body;
20
+ if (body || body === 0) {
21
+ let headers;
22
+ ({ body, headers, $type } = super.from({ body, headers: init.headers }));
23
+ init = { ...init, headers };
24
+ }
25
+
26
+ const instance = new this.constructor(body, init);
27
+
28
+ if (memoize) {
29
+ const cache = _meta(instance, 'cache');
30
+ const typeMap = { json: 'json', FormData: 'formData', text: 'text', ArrayBuffer: 'arrayBuffer', Blob: 'blob', Bytes: 'bytes' };
31
+ cache.set(typeMap[$type] || 'original', body);
32
+ }
33
+
34
+ return instance;
35
+ }
36
+
37
+ get status() {
38
+ // Support framework-injected app-level 'status'
39
+ return _meta(this).get('status') ?? super.status;
40
+ }
41
+
42
+ clone() {
43
+ const clone = super.clone();
44
+ ResponsePlus.upgradeInPlace(clone);
45
+
46
+ const responseMeta = _meta(this);
47
+ _wq(clone).set('meta', new Map(responseMeta));
48
+ if (responseMeta.has('cache')) {
49
+ responseMeta.set('cache', new Map(responseMeta.get('cache')));
50
+ }
51
+
52
+ return clone;
53
+ }
54
+ }
55
+
56
+