@webqit/fetch-plus 0.1.30 → 0.1.32

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/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "homepage": "https://fetch-plus.netlify.app/",
13
13
  "icon": "https://webqit.io/icon.svg",
14
- "version": "0.1.30",
14
+ "version": "0.1.32",
15
15
  "license": "MIT",
16
16
  "repository": {
17
17
  "type": "git",
@@ -111,34 +111,55 @@ export class HeadersPlus extends Headers {
111
111
 
112
112
  // Parse "Range" request header?
113
113
  if (/^Range$/i.test(name) && structured) {
114
+ const _after = (str, prefix) => str.includes(prefix) ? str.split(prefix)[1] : str;
115
+
114
116
  value = !value ? [] : _after(value, 'bytes=').split(',').map((rangeStr) => {
115
- const range = rangeStr.trim().split('-').map((s) => s ? parseInt(s, 10) : null);
117
+ if (!rangeStr.includes('-')) rangeStr = '-'; // -> [null, null];
118
+
119
+ // "0-499" -> [0, 499] | "500-" -> [500, null] | "-500" -> [null, 500]
120
+ const range = rangeStr.trim().split('-').map((s) => (s.length > 0 ? parseInt(s, 10) : null));
121
+
116
122
  range.resolveAgainst = (totalLength) => {
117
- const offsets = [...range];
118
- if (typeof offsets[1] !== 'number') {
123
+ const offsets = [...range]; // Clone the [start, end] array
124
+
125
+ // 1. Handle Suffix Ranges (e.g., bytes=-500)
126
+ if (offsets[0] === null && offsets[1] !== null) {
127
+ offsets[0] = Math.max(0, totalLength - offsets[1]);
119
128
  offsets[1] = totalLength - 1;
120
- } else {
121
- offsets[1] = Math.min(offsets[1], totalLength) - 1;
122
129
  }
123
- if (typeof offsets[0] !== 'number') {
124
- offsets[0] = offsets[1] ? totalLength - offsets[1] - 1 : 0;
130
+ // 2. Handle Open-ended Ranges (e.g., bytes=500-)
131
+ else if (offsets[0] !== null && offsets[1] === null) {
132
+ offsets[1] = totalLength - 1;
133
+ }
134
+ // 3. Handle Normal Ranges (e.g., bytes=0-499)
135
+ else if (offsets[0] !== null && offsets[1] !== null) {
136
+ offsets[1] = Math.min(offsets[1], totalLength - 1);
125
137
  }
126
- return offsets;
138
+
139
+ return offsets; // Returns [start, end] where both are inclusive indices
127
140
  };
141
+
128
142
  range.canResolveAgainst = (currentStart, totalLength) => {
129
- const offsets = [
130
- typeof range[0] === 'number' ? range[0] : currentStart,
131
- typeof range[1] === 'number' ? range[1] : totalLength - 1
132
- ];
133
- // Start higher than end or vice versa?
134
- if (offsets[0] > offsets[1]) return false;
135
- // Stretching beyond valid start/end?
136
- if (offsets[0] < currentStart || offsets[1] >= totalLength) return false;
143
+ const resolved = range.resolveAgainst(totalLength);
144
+
145
+ // 1. Check for NaN or unparsed nulls (invalid formats)
146
+ if (Number.isNaN(resolved[0]) || Number.isNaN(resolved[1]) || resolved[0] === null || resolved[1] === null) {
147
+ return false;
148
+ }
149
+
150
+ // 2. Validate start (end is always clamped):
151
+ // - Range cannot be inverted (start > end)
152
+ // - Start cannot be beyond file length
153
+ // - Start cannot be below file start
154
+ if (resolved[0] > resolved[1] || resolved[0] >= totalLength || resolved[0] < currentStart) {
155
+ return false;
156
+ }
157
+
137
158
  return true;
138
159
  };
139
- range.toString = () => {
140
- return rangeStr;
141
- };
160
+
161
+ range.toString = () => rangeStr;
162
+
142
163
  return range;
143
164
  });
144
165
  }
@@ -1,7 +1,7 @@
1
1
  import { _isObject, _isTypeObject } from '@webqit/util/js/index.js';
2
2
  import { Observer, ListenerRegistry, Descriptor } from '@webqit/observer';
3
3
  import { BroadcastChannelPlus, WebSocketPort, MessagePortPlus } from '@webqit/port-plus';
4
- import { isTypeStream, _meta, _wq } from './messageParserMixin.js';
4
+ import { isTypeStream, _meta, _wq, isAsyncIterable, isGenerator } from './messageParserMixin.js';
5
5
  import { ResponsePlus } from './ResponsePlus.js';
6
6
 
7
7
  export class LiveResponse extends EventTarget {
@@ -461,10 +461,13 @@ export class LiveResponse extends EventTarget {
461
461
  throw new Error(`frameClosure is not supported for responses.`);
462
462
  }
463
463
  frame.donePromise = execReplaceWithResponse(frame, body, frameOptions);
464
- } else if (isGenerator(body)) {
464
+ } else if (isAsyncIterable(body) || isGenerator(body)) {
465
465
  if (frameClosure) {
466
466
  throw new Error(`frameClosure is not supported for generators.`);
467
467
  }
468
+ if (!isGenerator(body)) {
469
+ body = body[Symbol.asyncIterator]();
470
+ }
468
471
  frame.donePromise = execReplaceWithGenerator(frame, body, frameOptions);
469
472
  } else if (body instanceof LiveProgramHandleX) {
470
473
  if (frameClosure) {
@@ -575,12 +578,6 @@ export class LiveResponse extends EventTarget {
575
578
  }
576
579
  }
577
580
 
578
- export const isGenerator = (obj) => {
579
- return typeof obj?.next === 'function' &&
580
- typeof obj?.throw === 'function' &&
581
- typeof obj?.return === 'function';
582
- };
583
-
584
581
  export class ReplaceEvent extends Event {
585
582
 
586
583
  [Symbol.toStringTag] = 'ReplaceEvent';
@@ -24,6 +24,14 @@ export function messageParserMixin(superClass) {
24
24
 
25
25
  // Process body
26
26
  let body = httpMessageInit.body;
27
+
28
+ if (isAsyncIterable(body) || isGenerator(body)) {
29
+ body = asyncIterableToStream(body);
30
+ const type = 'ReadableStream';
31
+ headers['content-type'] ??= 'application/octet-stream';
32
+ return { body, headers: new Headers(headers), $type: type };
33
+ }
34
+
27
35
  let type = [null, undefined].includes(body) ? null : dataType(body);
28
36
 
29
37
  // Binary bodies
@@ -203,6 +211,8 @@ export function dataType(value) {
203
211
  return null;
204
212
  }
205
213
 
214
+ // --------------
215
+
206
216
  export function isTypeReadable(obj) {
207
217
  return (
208
218
  obj !== null &&
@@ -217,3 +227,142 @@ export function isTypeStream(obj) {
217
227
  return obj instanceof ReadableStream
218
228
  || isTypeReadable(obj);
219
229
  }
230
+
231
+ // --------------
232
+
233
+ export const isGenerator = (obj) => {
234
+ return typeof obj?.next === 'function'
235
+ //&& typeof obj?.throw === 'function'
236
+ //&& typeof obj?.return === 'function';
237
+ };
238
+
239
+ export function isAsyncIterable(obj) {
240
+ return (
241
+ obj !== null &&
242
+ typeof obj === 'object' &&
243
+ typeof obj[Symbol.asyncIterator] === 'function'
244
+ );
245
+ }
246
+
247
+ export function asyncIterableToStream(iterable) {
248
+ if (!isAsyncIterable(iterable) && !isGenerator(body)) {
249
+ throw new TypeError('Body must be an async iterable.');
250
+ }
251
+
252
+ const iterator = isGenerator(iterable) ? iterable : iterable[Symbol.asyncIterator]();
253
+ const encoder = new TextEncoder();
254
+ let finished = false;
255
+
256
+ const closeIterator = async (reason) => {
257
+ if (finished) return;
258
+ finished = true;
259
+
260
+ if (typeof iterator.return === 'function') {
261
+ try {
262
+ await iterator.return(reason);
263
+ } catch { }
264
+ }
265
+ };
266
+
267
+ const encodeChunk = (value) => {
268
+ if (value == null) return null;
269
+
270
+ // Binary passthrough
271
+ if (value instanceof Uint8Array) {
272
+ return value;
273
+ }
274
+
275
+ // Text passthrough
276
+ if (typeof value === 'string') {
277
+ return encoder.encode(value);
278
+ }
279
+
280
+ // JSON (objects, arrays, numbers, booleans)
281
+ if (
282
+ typeof value === 'object' ||
283
+ typeof value === 'number' ||
284
+ typeof value === 'boolean'
285
+ ) {
286
+ return encoder.encode(JSON.stringify(value) + '\n');
287
+ }
288
+
289
+ throw new TypeError(
290
+ `Unsupported chunk type in async iterable: ${typeof value}`
291
+ );
292
+ };
293
+
294
+ return new ReadableStream({
295
+ async pull(controller) {
296
+ try {
297
+ const { value, done } = await iterator.next();
298
+
299
+ if (done) {
300
+ await closeIterator();
301
+ controller.close();
302
+ return;
303
+ }
304
+
305
+ const chunk = encodeChunk(value);
306
+ if (chunk) controller.enqueue(chunk);
307
+
308
+ } catch (err) {
309
+ await closeIterator();
310
+ controller.error(err);
311
+ }
312
+ },
313
+
314
+ async cancel(reason) {
315
+ await closeIterator(reason);
316
+ }
317
+ });
318
+ }
319
+
320
+ function streamToAsyncIterable(stream, { parse = null } = {}) {
321
+ const reader = stream.getReader();
322
+ const decoder = new TextDecoder();
323
+
324
+ let finished = false;
325
+ let buffer = '';
326
+
327
+ const close = async () => {
328
+ if (finished) return;
329
+ finished = true;
330
+ try {
331
+ await reader.cancel();
332
+ } catch { }
333
+ reader.releaseLock();
334
+ };
335
+
336
+ return {
337
+ async *[Symbol.asyncIterator]() {
338
+ try {
339
+ while (true) {
340
+ const { value, done } = await reader.read();
341
+ if (done) break;
342
+
343
+ if (parse === 'ndjson') {
344
+ buffer += decoder.decode(value, { stream: true });
345
+
346
+ let lines = buffer.split('\n');
347
+ buffer = lines.pop(); // incomplete fragment
348
+
349
+ for (const line of lines) {
350
+ if (line.trim()) yield JSON.parse(line);
351
+ }
352
+
353
+ continue;
354
+ }
355
+
356
+ yield value;
357
+ }
358
+
359
+ if (parse === 'ndjson' && buffer.trim()) {
360
+ yield JSON.parse(buffer);
361
+ }
362
+
363
+ } finally {
364
+ await close();
365
+ }
366
+ }
367
+ };
368
+ }
@@ -86,12 +86,12 @@ describe('Core API Tests', function () {
86
86
  // "range[0] < currentStart" is check.
87
87
  // If currentStart is 500, range 0-499 is NOT valid
88
88
 
89
- expect(range.canResolveAgainst(0, 400)).to.be.false; // range end > total
89
+ expect(range.canResolveAgainst(0, 400)).to.be.true; // range end > total, but clamped
90
90
 
91
91
  // Render
92
- // range is [0, 499]
92
+ // range is [0, 500] -> 501 bytes
93
93
  const rendered = range.resolveAgainst(1000);
94
- expect(rendered).to.deep.equal([0, 499]);
94
+ expect(rendered).to.deep.equal([0, 500]);
95
95
  });
96
96
 
97
97
  it('should handle open-ended ranges', function () {