@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.
@@ -1,4 +1,4 @@
1
- import { messageParserMixin, _meta, _wq } from './core.js';
1
+ import { messageParserMixin, _meta, _wq } from './messageParserMixin.js';
2
2
  import { HeadersPlus } from './HeadersPlus.js';
3
3
 
4
4
  export class RequestPlus extends messageParserMixin(Request) {
@@ -25,8 +25,8 @@ export class RequestPlus extends messageParserMixin(Request) {
25
25
  $type = $$type;
26
26
  }
27
27
 
28
- const instance = new this.constructor(url, init);
29
-
28
+ const instance = new this(url, init);
29
+
30
30
  if (memoize) {
31
31
  const cache = _meta(instance, 'cache');
32
32
  const typeMap = { json: 'json', FormData: 'formData', text: 'text', ArrayBuffer: 'arrayBuffer', Blob: 'blob', Bytes: 'bytes' };
@@ -48,7 +48,7 @@ export class RequestPlus extends messageParserMixin(Request) {
48
48
  : request[prop])
49
49
  }
50
50
  ), {});
51
- if (!['GET', 'HEAD'].includes(init.method?.toUpperCase() || request.method)) {
51
+ if (!['GET', 'HEAD'].includes(requestInit.method.toUpperCase())) {
52
52
  if ('body' in init) {
53
53
  requestInit.body = init.body
54
54
  if (!('headers' in init)) {
@@ -58,11 +58,13 @@ export class RequestPlus extends messageParserMixin(Request) {
58
58
  } else {
59
59
  requestInit.body = await request.clone().arrayBuffer();
60
60
  }
61
+ } else {
62
+ requestInit.body = null;
61
63
  }
62
64
  if (requestInit.mode === 'navigate') {
63
65
  requestInit.mode = 'cors';
64
66
  }
65
- return { url: request.url, ...requestInit };
67
+ return { url: init.url || request.url, ...requestInit };
66
68
  }
67
69
 
68
70
  clone() {
@@ -71,8 +73,8 @@ export class RequestPlus extends messageParserMixin(Request) {
71
73
 
72
74
  const requestMeta = _meta(this);
73
75
  _wq(clone).set('meta', new Map(requestMeta));
74
- if (requestMeta.has('cache')) {
75
- requestMeta.set('cache', new Map(requestMeta.get('cache')));
76
+ if (_meta(clone).has('cache')) {
77
+ _meta(clone).set('cache', new Map(requestMeta.get('cache')));
76
78
  }
77
79
 
78
80
  return clone;
@@ -1,4 +1,4 @@
1
- import { messageParserMixin, _meta, _wq } from './core.js';
1
+ import { messageParserMixin, _meta, _wq } from './messageParserMixin.js';
2
2
  import { HeadersPlus } from './HeadersPlus.js';
3
3
 
4
4
  export class ResponsePlus extends messageParserMixin(Response) {
@@ -18,14 +18,14 @@ export class ResponsePlus extends messageParserMixin(Response) {
18
18
  static from(body, { memoize = false, ...init } = {}) {
19
19
  if (body instanceof Response) return body;
20
20
 
21
- let $type, $body = body;
22
- if (body || body === 0) {
21
+ let $type;
22
+ if (typeof body !== 'undefined') {
23
23
  let headers;
24
24
  ({ body, headers, $type } = super.from({ body, headers: init.headers }));
25
25
  init = { ...init, headers };
26
26
  }
27
27
 
28
- const instance = new this.constructor(body, init);
28
+ const instance = new this(body, init);
29
29
 
30
30
  if (memoize) {
31
31
  const cache = _meta(instance, 'cache');
@@ -47,8 +47,8 @@ export class ResponsePlus extends messageParserMixin(Response) {
47
47
 
48
48
  const responseMeta = _meta(this);
49
49
  _wq(clone).set('meta', new Map(responseMeta));
50
- if (responseMeta.has('cache')) {
51
- responseMeta.set('cache', new Map(responseMeta.get('cache')));
50
+ if (_meta(clone).has('cache')) {
51
+ _meta(clone).set('cache', new Map(responseMeta.get('cache')));
52
52
  }
53
53
 
54
54
  return clone;
package/src/index.js CHANGED
@@ -4,5 +4,4 @@ export { ResponsePlus } from './ResponsePlus.js';
4
4
  export { HeadersPlus } from './HeadersPlus.js';
5
5
  export { FormDataPlus } from './FormDataPlus.js';
6
6
  export { LiveResponse } from './LiveResponse.js';
7
- export { URLSearchParamsPlus } from './URLSearchParamsPlus.js';
8
7
  export { default as Observer } from '@webqit/observer';
@@ -0,0 +1,217 @@
1
+ import { _isString, _isObject, _isTypeObject, _isNumber, _isBoolean, _isPlainObject, _isPlainArray } from '@webqit/util/js/index.js';
2
+ import { _wq as $wq } from '@webqit/util/js/index.js';
3
+ import { FormDataPlus } from './FormDataPlus.js';
4
+
5
+ export const _wq = (target, ...args) => $wq(target, 'fetch+', ...args);
6
+ export const _meta = (target, ...args) => $wq(target, 'fetch+', 'meta', ...args);
7
+
8
+ export function messageParserMixin(superClass) {
9
+ return class extends superClass {
10
+
11
+ static from(httpMessageInit) {
12
+ const headers = (httpMessageInit.headers instanceof Headers)
13
+
14
+ ? [...httpMessageInit.headers.entries()].reduce((_headers, [name, value]) => {
15
+ const key = name.toLowerCase();
16
+ _headers[key] = _headers[key] ? [].concat(_headers[key], value) : value;
17
+ return _headers;
18
+ }, {})
19
+
20
+ : Object.keys(httpMessageInit.headers || {}).reduce((_headers, name) => {
21
+ _headers[name.toLowerCase()] = httpMessageInit.headers[name];
22
+ return _headers;
23
+ }, {});
24
+
25
+ // Process body
26
+ let body = httpMessageInit.body;
27
+ let type = dataType(body);
28
+
29
+ // Binary bodies
30
+ if (['Blob', 'File'].includes(type)) {
31
+
32
+ headers['content-type'] ??= body.type;
33
+ headers['content-length'] ??= body.size;
34
+
35
+ } else if (['Uint8Array', 'Uint16Array', 'Uint32Array', 'ArrayBuffer'].includes(type)) {
36
+ headers['content-length'] ??= body.byteLength;
37
+ }
38
+
39
+ // JSON objects
40
+ else if (type === 'json' && _isTypeObject(body)) {
41
+ const { result: _body, isDirectlySerializable } = FormDataPlus.json(body, { encodeLiterals: true, meta: true });
42
+ if (isDirectlySerializable) {
43
+
44
+ body = JSON.stringify(body, (k, v) => v instanceof Error ? { ...v, message: v.message } : v);
45
+ headers['content-type'] = 'application/json';
46
+ headers['content-length'] = (new Blob([body])).size;
47
+
48
+ } else {
49
+ body = _body;
50
+ type = 'FormData';
51
+ }
52
+ }
53
+
54
+ // Strings
55
+ else if (['text', 'json'].includes(type) && !headers['content-length']) {
56
+ headers['content-length'] = (new Blob([body])).size;
57
+ }
58
+
59
+ if (!['FormData'].includes(type)
60
+ && !['function'].includes(typeof body)
61
+ && !headers['content-type']) {
62
+ headers['content-type'] = 'application/octet-stream';
63
+ }
64
+
65
+ // Return canonical init object with type info
66
+ return { body, headers: new Headers(headers), $type: type };
67
+ }
68
+
69
+ async formData() {
70
+ const fd = await super.formData();
71
+ FormDataPlus.upgradeInPlace(fd);
72
+ return fd;
73
+ }
74
+
75
+ async any({ to = null, memo = false } = {}) {
76
+ if (to && ![
77
+ 'blob', 'text', 'json', 'arrayBuffer', 'bytes', 'formData'
78
+ ].includes(to)) throw new Error(`Invalid target type specified: ${to}`);
79
+
80
+ const cache = _meta(this, 'cache');
81
+ const readAs = async (type) => {
82
+ // 1. Direct parsing
83
+ if (!memo) return await this[type || 'bytes']();
84
+
85
+ const byValue = (x) => {
86
+ if (x instanceof FormData) {
87
+ const clone = new FormDataPlus;
88
+ for (const [k, v] of x.entries()) clone.append(k, v);
89
+ return clone;
90
+ }
91
+ if ((!type || type === 'json')
92
+ && (_isPlainObject(x) || _isPlainArray(x))) {
93
+ return structuredClone(x);
94
+ }
95
+ return x;
96
+ };
97
+
98
+ // 2. Direct original
99
+ if (!type && cache.has('original')) return byValue(cache.has('original'));
100
+
101
+ // Default type
102
+ type ??= 'bytes';
103
+
104
+ // 3. Direct cache matching
105
+ if (cache.has(type)) return byValue(cache.get(type));
106
+
107
+ // 4. Clone + parse
108
+ let result;
109
+ if (cache.has('memo')) {
110
+ result = cache.get('memo').clone()[type]();
111
+ } else {
112
+ cache.set('memo', this.clone());
113
+ result = await this[type]();
114
+ }
115
+
116
+ cache.set(type, result);
117
+
118
+ return byValue(result);
119
+ };
120
+
121
+ const contentType = (this.headers.get('Content-Type') || '').split(';')[0].trim();
122
+ let result;
123
+ if ((!to || ['formData', 'json'].includes(to))
124
+ && ['multipart/form-data', 'application/x-www-form-urlencoded'].includes(contentType)) {
125
+ let fd = await readAs('formData');
126
+ FormDataPlus.upgradeInPlace(fd);
127
+
128
+ if (to === 'json') {
129
+ fd = await fd.json({ decodeLiterals: true });
130
+ }
131
+
132
+ result = fd;
133
+ } else if ((!to || ['formData', 'json'].includes(to))
134
+ && contentType === 'application/json') {
135
+ let json = await readAs('json');
136
+
137
+ if (to === 'formData') {
138
+ json = FormDataPlus.json(json, { encodeLiterals: true });
139
+ }
140
+
141
+ result = json;
142
+ } else if (!to && (
143
+ contentType.startsWith('image/') ||
144
+ contentType.startsWith('video/') ||
145
+ contentType.startsWith('audio/') ||
146
+ (contentType.startsWith('application/')
147
+ && !['xml', 'json', 'javascript', 'x-www-form-urlencoded'].some(t => contentType.includes(t)))
148
+ )) {
149
+ result = await readAs('blob');
150
+ } else if (!to && (
151
+ contentType.startsWith('text/') ||
152
+ (contentType.startsWith('application/')
153
+ && ['xml', 'javascript'].some((t) => contentType.includes(t))
154
+ ))) {
155
+ result = await readAs('text');
156
+ } else {
157
+ if (['json', 'formData'].includes(to)) {
158
+ throw new Error(`Cannot convert body of type ${contentType} to ${to}`);
159
+ }
160
+ result = await readAs(to);
161
+ }
162
+
163
+ return result;
164
+ }
165
+
166
+ forget() {
167
+ const cache = _meta(this, 'cache');
168
+ cache.clear();
169
+ }
170
+ };
171
+ }
172
+
173
+ // ------ Util
174
+
175
+ export function dataType(value) {
176
+ if (value instanceof FormData) {
177
+ return 'FormData';
178
+ }
179
+ if (value === null || _isNumber(value) || _isBoolean(value)) {
180
+ return 'json';
181
+ }
182
+ if (_isString(value)) {
183
+ return 'text';
184
+ }
185
+
186
+ if (_isTypeObject(value)) {
187
+ const toStringTag = value[Symbol.toStringTag];
188
+ const type = [
189
+ 'Uint8Array', 'Uint16Array', 'Uint32Array', 'ArrayBuffer', 'Blob', 'File', 'FormData', 'Stream', 'ReadableStream'
190
+ ].reduce((_toStringTag, type) => _toStringTag || (toStringTag === type ? type : null), null);
191
+
192
+ if (type) return type;
193
+
194
+ if (_isObject(value) || Array.isArray(value)) {
195
+ return 'json';
196
+ }
197
+
198
+ if ('toString' in value) return 'text';
199
+ }
200
+
201
+ return null;
202
+ }
203
+
204
+ export function isTypeReadable(obj) {
205
+ return (
206
+ obj !== null &&
207
+ typeof obj === 'object' &&
208
+ typeof obj.read === 'function' && // streams have .read()
209
+ typeof obj.pipe === 'function' && // streams have .pipe()
210
+ typeof obj.on === 'function' // streams have event listeners
211
+ );
212
+ }
213
+
214
+ export function isTypeStream(obj) {
215
+ return obj instanceof ReadableStream
216
+ || isTypeReadable(obj);
217
+ }
@@ -0,0 +1,314 @@
1
+ import { expect } from 'chai';
2
+ import { HeadersPlus } from '../src/HeadersPlus.js';
3
+ import { RequestPlus } from '../src/RequestPlus.js';
4
+ import { ResponsePlus } from '../src/ResponsePlus.js';
5
+ import { FormDataPlus } from '../src/FormDataPlus.js';
6
+ import { fetchPlus } from '../src/fetchPlus.js';
7
+
8
+ describe('Core API Tests', function () {
9
+
10
+ describe('HeadersPlus', function () {
11
+ it('should parse "Cookie" request headers', function () {
12
+ const headers = new HeadersPlus({
13
+ 'Cookie': 'name=value; name2=value2'
14
+ });
15
+ const cookies = headers.get('Cookie', true);
16
+ expect(cookies).to.be.an('array').with.lengthOf(2);
17
+ expect(cookies[0]).to.deep.include({ name: 'name', value: 'value' });
18
+ expect(cookies[1]).to.deep.include({ name: 'name2', value: 'value2' });
19
+ });
20
+
21
+ it('should parse "Set-Cookie" response headers', function () {
22
+ // Note: Native Headers object often combines multiple Set-Cookie headers oddly or hides them,
23
+ // but HeadersPlus aims to handle them if possible or at least parse single strings.
24
+ // Let's test basic single parsing first as multiple Set-Cookie support varies by environment (Node vs Browser).
25
+ const headers = new HeadersPlus();
26
+ headers.append('Set-Cookie', 'session=123; Secure; Path=/');
27
+
28
+ // getSetCookie might be polyfilled or native
29
+ const cookies = headers.get('Set-Cookie', true);
30
+ expect(cookies).to.be.an('array');
31
+ expect(cookies[0]).to.deep.include({ name: 'session', value: '123' });
32
+ expect(cookies[0]).to.have.property('secure', true);
33
+ expect(cookies[0]).to.have.property('path', '/');
34
+ });
35
+
36
+ it('should parse "Range" headers', function () {
37
+ const headers = new HeadersPlus({
38
+ 'Range': 'bytes=0-499, 500-999'
39
+ });
40
+ const ranges = headers.get('Range', true);
41
+ expect(ranges).to.have.lengthOf(2);
42
+ expect(ranges[0]).to.deep.equal([0, 499]);
43
+ expect(ranges[1]).to.deep.equal([500, 999]);
44
+ });
45
+
46
+ it('should parse "Accept" headers and match mime types', function () {
47
+ const headers = new HeadersPlus({
48
+ 'Accept': 'text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8'
49
+ });
50
+ const accept = headers.get('Accept', true);
51
+
52
+ expect(accept).to.deep.equal([
53
+ ['text/html', 1],
54
+ ['application/xhtml+xml', 1],
55
+ ['application/xml', 0.9],
56
+ ['*/*', 0.8],
57
+ ]);
58
+ expect(accept.toString()).to.equal('text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8');
59
+ expect(accept.match('text/html')).to.equal(1);
60
+ expect(accept.match('application/xml')).to.equal(0.9);
61
+ expect(accept.match('image/png')).to.equal(0.8); // Matches */*
62
+ });
63
+ });
64
+
65
+ describe('HeadersPlus (Extended)', function () {
66
+ it('should parse multi-range headers', function () {
67
+ const headers = new HeadersPlus({
68
+ 'Range': 'bytes=0-50, 60-100'
69
+ });
70
+ const ranges = headers.get('Range', true);
71
+ expect(ranges).to.have.lengthOf(2);
72
+ expect(ranges[0]).to.deep.equal([0, 50]);
73
+ expect(ranges[1]).to.deep.equal([60, 100]);
74
+ });
75
+
76
+ it('should validate and render ranges', function () {
77
+ const headers = new HeadersPlus({
78
+ 'Range': 'bytes=0-500'
79
+ });
80
+ const range = headers.get('Range', true)[0];
81
+
82
+ // Validate against total length
83
+ expect(range.canResolveAgainst(0, 1000)).to.be.true;
84
+ expect(range.canResolveAgainst(500, 1000)).to.be.false; // Start > 0 is fine, but range covers 0-500?
85
+ // wait, canResolveAgainst(currentStart, totalLength) checks if the range is valid for the current content?
86
+ // "range[0] < currentStart" is check.
87
+ // If currentStart is 500, range 0-499 is NOT valid
88
+
89
+ expect(range.canResolveAgainst(0, 400)).to.be.false; // range end > total
90
+
91
+ // Render
92
+ // range is [0, 499]
93
+ const rendered = range.resolveAgainst(1000);
94
+ expect(rendered).to.deep.equal([0, 499]);
95
+ });
96
+
97
+ it('should handle open-ended ranges', function () {
98
+ const headers = new HeadersPlus({ 'Range': 'bytes=500-' });
99
+ const range = headers.get('Range', true)[0];
100
+ // [500, null]
101
+
102
+ const rendered = range.resolveAgainst(1000);
103
+ expect(rendered).to.deep.equal([500, 999]);
104
+ });
105
+
106
+ it('should handle multiple Set-Cookie headers', function () {
107
+ const headers = new HeadersPlus();
108
+ headers.append('Set-Cookie', 'a=1; Path=/');
109
+ headers.append('Set-Cookie', 'b=2; Secure');
110
+
111
+ const cookies = headers.get('Set-Cookie', true);
112
+ expect(cookies).to.be.an('array').with.lengthOf(2);
113
+ expect(cookies[0]).to.deep.include({ name: 'a', value: '1' });
114
+ expect(cookies[1]).to.deep.include({ name: 'b', value: '2' });
115
+ });
116
+
117
+ it('should return 0 for matching unknown types in Accept header without wildcard', function () {
118
+ const headers = new HeadersPlus({
119
+ 'Accept': 'text/html, application/json;q=0.9'
120
+ });
121
+ const accept = headers.get('Accept', true);
122
+
123
+ expect(accept.match('text/html')).to.equal(1);
124
+ expect(accept.match('image/png')).to.equal(0);
125
+ });
126
+ });
127
+
128
+ describe('RequestPlus & ResponsePlus', function () {
129
+ it('should support upgradeInPlace for Request', function () {
130
+ const req = new Request('http://example.com');
131
+ expect(req).to.not.be.instanceOf(RequestPlus);
132
+
133
+ RequestPlus.upgradeInPlace(req);
134
+ expect(req).to.be.instanceOf(RequestPlus);
135
+
136
+ expect(req.headers).to.be.instanceOf(Headers); // It is still headers
137
+ expect(req.headers).to.be.instanceOf(HeadersPlus); // It is now also headersPlus
138
+
139
+ req.headers.set('Range', 'bytes=0-100');
140
+ const range = req.headers.get('Range', true); // Should return array if upgraded
141
+ expect(range).to.be.an('array');
142
+ });
143
+
144
+ it('should support upgradeInPlace for Response', function () {
145
+ const res = new Response('body');
146
+ expect(res).to.not.be.instanceOf(ResponsePlus);
147
+
148
+ ResponsePlus.upgradeInPlace(res);
149
+ expect(res).to.be.instanceOf(ResponsePlus);
150
+
151
+ expect(res.headers).to.be.instanceOf(Headers); // It is still headers
152
+ expect(res.headers).to.be.instanceOf(HeadersPlus); // It is now also headersPlus
153
+
154
+ res.headers.set('Content-Range', 'bytes 0-100/1000');
155
+ const range = res.headers.get('Content-Range', true);
156
+ expect(range).to.deep.equal(['0-100', '1000']);
157
+ });
158
+
159
+ it('should create instances via .from()', function () {
160
+ const req = RequestPlus.from('http://example.com', { method: 'POST' });
161
+ expect(req).to.be.instanceOf(RequestPlus);
162
+ expect(req.method).to.equal('POST');
163
+
164
+ const res = ResponsePlus.from('content', { status: 201 });
165
+ expect(res).to.be.instanceOf(ResponsePlus);
166
+ expect(res.status).to.equal(201);
167
+ });
168
+
169
+ it('should provide extended body parsing (json)', async function () {
170
+ const data = { foo: 'bar' };
171
+ const res = new ResponsePlus(JSON.stringify(data), {
172
+ headers: { 'Content-Type': 'application/json' }
173
+ });
174
+
175
+ const json = await res.json();
176
+ expect(json).to.deep.equal(data);
177
+ });
178
+
179
+ it('should support .any() with memoization and conversions', async function () {
180
+ const data = { foo: 'bar', prefs: [1, 2], loves_it: true };
181
+
182
+ // 1. Auto-detect json
183
+ const res1 = new ResponsePlus(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
184
+ const json = await res1.any();
185
+ expect(json).to.deep.equal(data);
186
+
187
+ // 2. Memoization (should return same object on subsequent calls)
188
+ const res2 = new ResponsePlus(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
189
+ const j1 = await res2.any({ memo: true }); // Returns original, caches clone
190
+ const j2 = await res2.any({ memo: true }); // Returns cached clone
191
+ const j3 = await res2.any({ memo: true }); // Returns cached clone
192
+ expect(j2).to.deep.equal(j1);
193
+ expect(j3).to.deep.equal(j2); // Non-strict equality for cached hits
194
+
195
+ // 3. Conversion JSON -> FormData
196
+ // If we ask for formData from a JSON response
197
+ const res3 = new ResponsePlus(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
198
+ const fd = await res3.any({ to: 'formData' });
199
+ // It should convert
200
+ expect(await fd.json()).to.deep.equal(data);
201
+ });
202
+
203
+ it('should auto-generate headers in .from()', async function () {
204
+ // 1. JSON
205
+ const res1 = ResponsePlus.from({ a: 1 });
206
+ // console.log('res1 headers:', [...res1.headers.entries()]);
207
+ expect(res1.headers.get('Content-Type')).to.be.a('string').that.includes('application/json');
208
+ expect(res1.headers.get('Content-Length')).to.exist;
209
+
210
+ // 2. String
211
+ const res2 = ResponsePlus.from('hello');
212
+ expect(res2.headers.get('Content-Length')).to.equal('5');
213
+
214
+ // 3. Binary (Uint8Array)
215
+ const buffer = new TextEncoder().encode('hello');
216
+ const res3 = ResponsePlus.from(buffer);
217
+ expect(res3.headers.get('Content-Length')).to.equal('5');
218
+
219
+ // 4. Blob
220
+ const blob = new Blob(['hello'], { type: 'text/plain' });
221
+ const res4 = ResponsePlus.from(blob);
222
+ expect(res4.headers.get('Content-Type')).to.equal('text/plain');
223
+ expect(res4.headers.get('Content-Length')).to.equal('5');
224
+ });
225
+
226
+ it('should auto-generate headers for specialized inputs in .from()', async function () {
227
+ const url = 'http://url';
228
+
229
+ // FormData
230
+ const req1 = RequestPlus.from(url, { body: new FormData(), method: 'POST' });
231
+ // Auto Content-Type: `multipart/form-data`
232
+ expect(req1.headers.get('Content-Type')).to.be.a('string').that.includes('multipart/form-data');
233
+ expect(req1.headers.get('Content-Length')).to.not.exist;
234
+
235
+ // JSON object with complex data types
236
+ const body2 = {
237
+ name: 'John Doe',
238
+ avatars: {
239
+ primary: new Blob(['imageBytes1'], { type: 'image/png' }),
240
+ secondary: new Blob(['imageBytes2'], { type: 'image/png' }),
241
+ },
242
+ loves_it: true,
243
+ };
244
+ const req2 = RequestPlus.from(url, { body: body2, method: 'POST' });
245
+ // Auto Content-Type: `multipart/form-data`
246
+ expect(req2.headers.get('Content-Type')).to.be.a('string').that.includes('multipart/form-data');
247
+ expect(req2.headers.get('Content-Length')).to.not.exist;
248
+
249
+ // TypeArray
250
+ const req3 = RequestPlus.from(url, { body: new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), method: 'POST' });
251
+ // Auto Content-Type: `application/octet-stream`
252
+ // Auto Content-Length: <number>
253
+ expect(req3.headers.get('Content-Type')).to.be.a('string').that.includes('application/octet-stream');
254
+ expect(req3.headers.get('Content-Length')).to.exist;
255
+
256
+ // Blob
257
+ const req4 = RequestPlus.from(url, { body: new Blob(['hello'], { type: 'text/plain' }), method: 'POST' });
258
+ // Auto Content-Type: `text/plain`
259
+ // Auto Content-Length: <number>
260
+ expect(req4.headers.get('Content-Type')).to.be.a('string').that.includes('text/plain');
261
+ expect(req4.headers.get('Content-Length')).to.exist;
262
+ });
263
+ });
264
+
265
+ describe('FormDataPlus', function () {
266
+ it('should convert FormData to JSON', async function () {
267
+ const fd = new FormDataPlus();
268
+ fd.append('name', 'Alice');
269
+ fd.append('age', '30');
270
+ fd.append('skills[0]', 'JS');
271
+ fd.append('skills[]', 'Testing');
272
+
273
+ const json = await fd.json();
274
+ expect(json).to.deep.equal({
275
+ name: 'Alice',
276
+ age: 30,
277
+ skills: ['JS', 'Testing']
278
+ });
279
+ });
280
+
281
+ // Nested structure if supported?
282
+ it('should support nested structures in FormData keys', async function () {
283
+ const fd = new FormDataPlus();
284
+ fd.append('user[name]', 'Bob');
285
+ fd.append('user[address][city]', 'New York');
286
+
287
+ const json = await fd.json();
288
+ expect(json).to.deep.equal({
289
+ user: {
290
+ name: 'Bob',
291
+ address: {
292
+ city: 'New York'
293
+ }
294
+ }
295
+ });
296
+ });
297
+ });
298
+
299
+ describe('fetchPlus', function () {
300
+ it('should return a ResponsePlus', async function () {
301
+ // Mock global fetch
302
+ const originalFetch = globalThis.fetch;
303
+ globalThis.fetch = async () => new Response('ok');
304
+
305
+ const res = await fetchPlus('http://mock.url');
306
+ // Check if it has extended capabilities, e.g. upgraded headers
307
+ res.headers.set('Range', 'bytes=0-10');
308
+ expect(res.headers.get('Range', true)).to.be.an('array');
309
+
310
+ globalThis.fetch = originalFetch;
311
+ });
312
+ });
313
+
314
+ });