@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/README.md +5 -5
- package/dist/main.js +2 -1
- package/dist/main.js.map +3 -3
- package/package.json +1 -1
- package/src/HeadersPlus.js +40 -19
- package/src/LiveResponse.js +5 -8
- package/src/messageParserMixin.js +149 -0
- package/test/1.basic.test.js +3 -3
package/package.json
CHANGED
package/src/HeadersPlus.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
138
|
+
|
|
139
|
+
return offsets; // Returns [start, end] where both are inclusive indices
|
|
127
140
|
};
|
|
141
|
+
|
|
128
142
|
range.canResolveAgainst = (currentStart, totalLength) => {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
]
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
160
|
+
|
|
161
|
+
range.toString = () => rangeStr;
|
|
162
|
+
|
|
142
163
|
return range;
|
|
143
164
|
});
|
|
144
165
|
}
|
package/src/LiveResponse.js
CHANGED
|
@@ -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
|
+
}
|
package/test/1.basic.test.js
CHANGED
|
@@ -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.
|
|
89
|
+
expect(range.canResolveAgainst(0, 400)).to.be.true; // range end > total, but clamped
|
|
90
90
|
|
|
91
91
|
// Render
|
|
92
|
-
// range is [0,
|
|
92
|
+
// range is [0, 500] -> 501 bytes
|
|
93
93
|
const rendered = range.resolveAgainst(1000);
|
|
94
|
-
expect(rendered).to.deep.equal([0,
|
|
94
|
+
expect(rendered).to.deep.equal([0, 500]);
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
it('should handle open-ended ranges', function () {
|