@gjsify/fetch 0.34.0 → 0.36.0

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/lib/body.d.ts CHANGED
@@ -19,6 +19,8 @@ export default class Body {
19
19
  [INTERNALS]: {
20
20
  body: null | Buffer | Readable | Blob;
21
21
  stream: Readable | null;
22
+ /** Memoized `body` wrapper — see the getter for why identity matters. */
23
+ webStream: ReadableStream<Uint8Array> | null;
22
24
  boundary: string;
23
25
  disturbed: boolean;
24
26
  error: null | FetchBaseError;
@@ -28,6 +30,22 @@ export default class Body {
28
30
  size?: number;
29
31
  headers?: unknown;
30
32
  });
33
+ /**
34
+ * The body as a `ReadableStream`.
35
+ *
36
+ * Per the Fetch spec `body` is an ATTRIBUTE holding one stream, so every
37
+ * read must return the SAME object — `res.body === res.body`. This used to
38
+ * build a fresh wrapper per access, each one subscribing to the same
39
+ * already-flowing `Readable`: the first reader got every byte and every
40
+ * later one got an immediate `done`, with no error anywhere.
41
+ *
42
+ * That is not a theoretical spec detail. Consumers legitimately touch
43
+ * `.body` several times in a row — three.js' `FileLoader` guards with
44
+ * `response.body === undefined`, then `response.body.getReader === undefined`,
45
+ * and only then calls `response.body.getReader()`, so it read the THIRD,
46
+ * empty stream. Downstream: an empty response text, an LDraw model that
47
+ * parsed to zero meshes, and a viewer showing nothing but its clear colour.
48
+ */
31
49
  get body(): ReadableStream<Uint8Array> | null;
32
50
  get _stream(): Readable;
33
51
  /** Return the raw body buffer without consuming the stream (used by Request._send). */
package/lib/body.js CHANGED
@@ -34,6 +34,7 @@ export default class Body {
34
34
  [INTERNALS] = {
35
35
  body: null,
36
36
  stream: null,
37
+ webStream: null,
37
38
  boundary: '',
38
39
  disturbed: false,
39
40
  error: null,
@@ -110,14 +111,33 @@ export default class Body {
110
111
  });
111
112
  }
112
113
  }
114
+ /**
115
+ * The body as a `ReadableStream`.
116
+ *
117
+ * Per the Fetch spec `body` is an ATTRIBUTE holding one stream, so every
118
+ * read must return the SAME object — `res.body === res.body`. This used to
119
+ * build a fresh wrapper per access, each one subscribing to the same
120
+ * already-flowing `Readable`: the first reader got every byte and every
121
+ * later one got an immediate `done`, with no error anywhere.
122
+ *
123
+ * That is not a theoretical spec detail. Consumers legitimately touch
124
+ * `.body` several times in a row — three.js' `FileLoader` guards with
125
+ * `response.body === undefined`, then `response.body.getReader === undefined`,
126
+ * and only then calls `response.body.getReader()`, so it read the THIRD,
127
+ * empty stream. Downstream: an empty response text, an LDraw model that
128
+ * parsed to zero meshes, and a viewer showing nothing but its clear colour.
129
+ */
113
130
  get body() {
114
131
  const stream = this[INTERNALS].stream;
115
132
  if (!stream)
116
133
  return null;
134
+ const memoized = this[INTERNALS].webStream;
135
+ if (memoized)
136
+ return memoized;
117
137
  // If ReadableStream is available, wrap the Readable into one
118
138
  if (typeof ReadableStream !== 'undefined') {
119
139
  let closed = false;
120
- return new ReadableStream({
140
+ const webStream = new ReadableStream({
121
141
  start(controller) {
122
142
  stream.on('data', (chunk) => {
123
143
  if (closed)
@@ -160,6 +180,8 @@ export default class Body {
160
180
  stream.destroy();
161
181
  },
162
182
  });
183
+ this[INTERNALS].webStream = webStream;
184
+ return webStream;
163
185
  }
164
186
  return null;
165
187
  }
package/lib/esm/body.js CHANGED
@@ -1 +1 @@
1
- import{__name as e}from"./_virtual/_rolldown/runtime.js";import{Blob as t}from"./utils/blob-from.js";import{FetchBaseError as n}from"./errors/base.js";import{FetchError as r}from"./errors/fetch-error.js";import{isBlob as i,isURLSearchParameters as a}from"./utils/is.js";import{URLSearchParams as o}from"@gjsify/url";import{Buffer as s}from"node:buffer";import{PassThrough as c,Readable as l,Stream as u,pipeline as d}from"node:stream";import{FormData as f,formDataToBlob as p}from"@gjsify/formdata";const m=e((e,t)=>new Promise((n,r)=>{d(e,t,e=>{e?r(e):n()})}),`pipeline`),h=Symbol(`Body internals`);function isAnyArrayBuffer(e){return e instanceof ArrayBuffer||typeof SharedArrayBuffer<`u`&&e instanceof SharedArrayBuffer}var Body=class{[h]={body:null,stream:null,boundary:``,disturbed:!1,error:null};size=0;constructor(e,t={size:0}){if(this.size=t.size||0,e==null)this[h].body=null;else if(a(e))this[h].body=s.from(e.toString());else if(i(e))this[h].body=e;else if(s.isBuffer(e))this[h].body=e;else if(isAnyArrayBuffer(e))this[h].body=s.from(e);else if(ArrayBuffer.isView(e))this[h].body=s.from(e.buffer,e.byteOffset,e.byteLength);else if(e instanceof l)this[h].body=e;else if(typeof ReadableStream<`u`&&e instanceof ReadableStream)this[h].body=readableStreamToReadable(e);else if(e instanceof f){let t=p(e);this[h].body=t,this[h].boundary=t.type?.split(`boundary=`)?.[1]??``}else typeof e==`string`?this[h].body=s.from(e):e instanceof o?this[h].body=s.from(e.toString()):(console.warn(`Unknown body type "${typeof e}", try to parse the body to string!`),this[h].body=s.from(String(e)));let c=this[h].body;s.isBuffer(c)?this[h].stream=l.from(c):i(c)?this[h].stream=l.from(blobToAsyncIterable(c)):c instanceof l&&(this[h].stream=c),c instanceof u&&c.on(`error`,e=>{let t=e instanceof n?e:new r(`Invalid response body while trying to fetch ${this.url}: ${e.message}`,`system`,e);this[h].error=t})}get body(){let e=this[h].stream;if(!e)return null;if(typeof ReadableStream<`u`){let t=!1;return new ReadableStream({start(n){e.on(`data`,e=>{if(!t)try{n.enqueue(e instanceof Uint8Array?e:new Uint8Array(e))}catch{}}),e.on(`end`,()=>{if(!t){t=!0;try{n.close()}catch{}}}),e.on(`error`,e=>{if(!t){t=!0;try{n.error(e)}catch{}}})},cancel(){t=!0,e.destroy()}})}return null}get _stream(){return this[h].stream}get _rawBodyBuffer(){let e=this[h].body;return e===null?null:s.isBuffer(e)?e:e instanceof Uint8Array?s.from(e.buffer,e.byteOffset,e.byteLength):null}get bodyUsed(){return this[h].disturbed}async arrayBuffer(){let{buffer:e,byteOffset:t,byteLength:n}=await consumeBody(this);return e.slice(t,t+n)}async formData(){let e=this.headers?.get(`content-type`);if(e?.startsWith(`application/x-www-form-urlencoded`)){let e=new f,t=new o(await this.text());for(let[n,r]of t)e.append(n,r);return e}let{toFormData:t}=await import(`./utils/multipart-parser.js`);return t(this.body,e)}async blob(){let e=this.headers?.get(`content-type`)||this[h].body&&this[h].body.type||``;return new t([await this.arrayBuffer()],{type:e})}async json(){let e=await this.text();return JSON.parse(e)}async text(){let e=await consumeBody(this);return new TextDecoder().decode(e)}};Object.defineProperties(Body.prototype,{body:{enumerable:!0},bodyUsed:{enumerable:!0},arrayBuffer:{enumerable:!0},blob:{enumerable:!0},json:{enumerable:!0},text:{enumerable:!0}});async function consumeBody(e){if(e[h].disturbed)throw TypeError(`body used already for: ${e.url}`);if(e[h].disturbed=!0,e[h].error)throw e[h].error;let{_stream:t}=e;if(t===null||!(t instanceof u))return s.alloc(0);let i=[],a=0;try{for await(let n of t){if(e.size>0&&a+n.length>e.size){let n=new r(`content size at ${e.url} over limit: ${e.size}`,`max-size`);throw t.destroy(n),n}a+=n.length,i.push(n)}}catch(t){let i=t instanceof Error?t:Error(String(t));throw t instanceof n?t:new r(`Invalid response body while trying to fetch ${e.url}: ${i.message}`,`system`,i)}try{return i.every(e=>typeof e==`string`)?s.from(i.join(``)):s.concat(i,a)}catch(t){let n=t instanceof Error?t:Error(String(t));throw new r(`Could not create Buffer from response body for ${e.url}: ${n.message}`,`system`,n)}}const clone=(e,t)=>{let n,r,{body:i}=e[h];if(e.bodyUsed)throw Error(`cannot clone body after it is used`);return i instanceof u&&typeof i.getBoundary!=`function`&&(n=new c({highWaterMark:t}),r=new c({highWaterMark:t}),i.pipe(n),i.pipe(r),e[h].stream=n,i=r),i},extractContentType=(e,t)=>e===null?null:typeof e==`string`?`text/plain;charset=UTF-8`:a(e)?`application/x-www-form-urlencoded;charset=UTF-8`:i(e)?e.type||null:s.isBuffer(e)||isAnyArrayBuffer(e)||ArrayBuffer.isView(e)?null:e instanceof f?`multipart/form-data; boundary=${t[h].boundary}`:e instanceof u?null:`text/plain;charset=UTF-8`,getTotalBytes=e=>{let{body:t}=e[h];if(t===null)return 0;if(i(t))return t.size;if(s.isBuffer(t))return t.length;if(t&&typeof t.getLengthSync==`function`){let e=t;return e.hasKnownLength&&e.hasKnownLength()?e.getLengthSync():null}return null},writeToStream=async(e,{body:t})=>{t===null?e.end():await m(t,e)};function readableStreamToReadable(e){let t=e.getReader();return new l({async read(){try{let{done:e,value:n}=await t.read();e?this.push(null):this.push(s.from(n))}catch(e){this.destroy(e)}},destroy(e,n){t.cancel().then(()=>n(null),n)}})}async function*blobToAsyncIterable(e){if(typeof e.stream==`function`){let t=e.stream().getReader();for(;;){let{done:e,value:n}=await t.read();if(e)break;yield n}}else yield new Uint8Array(await e.arrayBuffer())}export{clone,Body as default,extractContentType,getTotalBytes,writeToStream};
1
+ import{__name as e}from"./_virtual/_rolldown/runtime.js";import{Blob as t}from"./utils/blob-from.js";import{FetchBaseError as n}from"./errors/base.js";import{FetchError as r}from"./errors/fetch-error.js";import{isBlob as i,isURLSearchParameters as a}from"./utils/is.js";import{URLSearchParams as o}from"@gjsify/url";import{Buffer as s}from"node:buffer";import{PassThrough as c,Readable as l,Stream as u,pipeline as d}from"node:stream";import{FormData as f,formDataToBlob as p}from"@gjsify/formdata";const m=e((e,t)=>new Promise((n,r)=>{d(e,t,e=>{e?r(e):n()})}),`pipeline`),h=Symbol(`Body internals`);function isAnyArrayBuffer(e){return e instanceof ArrayBuffer||typeof SharedArrayBuffer<`u`&&e instanceof SharedArrayBuffer}var Body=class{[h]={body:null,stream:null,webStream:null,boundary:``,disturbed:!1,error:null};size=0;constructor(e,t={size:0}){if(this.size=t.size||0,e==null)this[h].body=null;else if(a(e))this[h].body=s.from(e.toString());else if(i(e))this[h].body=e;else if(s.isBuffer(e))this[h].body=e;else if(isAnyArrayBuffer(e))this[h].body=s.from(e);else if(ArrayBuffer.isView(e))this[h].body=s.from(e.buffer,e.byteOffset,e.byteLength);else if(e instanceof l)this[h].body=e;else if(typeof ReadableStream<`u`&&e instanceof ReadableStream)this[h].body=readableStreamToReadable(e);else if(e instanceof f){let t=p(e);this[h].body=t,this[h].boundary=t.type?.split(`boundary=`)?.[1]??``}else typeof e==`string`?this[h].body=s.from(e):e instanceof o?this[h].body=s.from(e.toString()):(console.warn(`Unknown body type "${typeof e}", try to parse the body to string!`),this[h].body=s.from(String(e)));let c=this[h].body;s.isBuffer(c)?this[h].stream=l.from(c):i(c)?this[h].stream=l.from(blobToAsyncIterable(c)):c instanceof l&&(this[h].stream=c),c instanceof u&&c.on(`error`,e=>{let t=e instanceof n?e:new r(`Invalid response body while trying to fetch ${this.url}: ${e.message}`,`system`,e);this[h].error=t})}get body(){let e=this[h].stream;if(!e)return null;let t=this[h].webStream;if(t)return t;if(typeof ReadableStream<`u`){let t=!1,n=new ReadableStream({start(n){e.on(`data`,e=>{if(!t)try{n.enqueue(e instanceof Uint8Array?e:new Uint8Array(e))}catch{}}),e.on(`end`,()=>{if(!t){t=!0;try{n.close()}catch{}}}),e.on(`error`,e=>{if(!t){t=!0;try{n.error(e)}catch{}}})},cancel(){t=!0,e.destroy()}});return this[h].webStream=n,n}return null}get _stream(){return this[h].stream}get _rawBodyBuffer(){let e=this[h].body;return e===null?null:s.isBuffer(e)?e:e instanceof Uint8Array?s.from(e.buffer,e.byteOffset,e.byteLength):null}get bodyUsed(){return this[h].disturbed}async arrayBuffer(){let{buffer:e,byteOffset:t,byteLength:n}=await consumeBody(this);return e.slice(t,t+n)}async formData(){let e=this.headers?.get(`content-type`);if(e?.startsWith(`application/x-www-form-urlencoded`)){let e=new f,t=new o(await this.text());for(let[n,r]of t)e.append(n,r);return e}let{toFormData:t}=await import(`./utils/multipart-parser.js`);return t(this.body,e)}async blob(){let e=this.headers?.get(`content-type`)||this[h].body&&this[h].body.type||``;return new t([await this.arrayBuffer()],{type:e})}async json(){let e=await this.text();return JSON.parse(e)}async text(){let e=await consumeBody(this);return new TextDecoder().decode(e)}};Object.defineProperties(Body.prototype,{body:{enumerable:!0},bodyUsed:{enumerable:!0},arrayBuffer:{enumerable:!0},blob:{enumerable:!0},json:{enumerable:!0},text:{enumerable:!0}});async function consumeBody(e){if(e[h].disturbed)throw TypeError(`body used already for: ${e.url}`);if(e[h].disturbed=!0,e[h].error)throw e[h].error;let{_stream:t}=e;if(t===null||!(t instanceof u))return s.alloc(0);let i=[],a=0;try{for await(let n of t){if(e.size>0&&a+n.length>e.size){let n=new r(`content size at ${e.url} over limit: ${e.size}`,`max-size`);throw t.destroy(n),n}a+=n.length,i.push(n)}}catch(t){let i=t instanceof Error?t:Error(String(t));throw t instanceof n?t:new r(`Invalid response body while trying to fetch ${e.url}: ${i.message}`,`system`,i)}try{return i.every(e=>typeof e==`string`)?s.from(i.join(``)):s.concat(i,a)}catch(t){let n=t instanceof Error?t:Error(String(t));throw new r(`Could not create Buffer from response body for ${e.url}: ${n.message}`,`system`,n)}}const clone=(e,t)=>{let n,r,{body:i}=e[h];if(e.bodyUsed)throw Error(`cannot clone body after it is used`);return i instanceof u&&typeof i.getBoundary!=`function`&&(n=new c({highWaterMark:t}),r=new c({highWaterMark:t}),i.pipe(n),i.pipe(r),e[h].stream=n,i=r),i},extractContentType=(e,t)=>e===null?null:typeof e==`string`?`text/plain;charset=UTF-8`:a(e)?`application/x-www-form-urlencoded;charset=UTF-8`:i(e)?e.type||null:s.isBuffer(e)||isAnyArrayBuffer(e)||ArrayBuffer.isView(e)?null:e instanceof f?`multipart/form-data; boundary=${t[h].boundary}`:e instanceof u?null:`text/plain;charset=UTF-8`,getTotalBytes=e=>{let{body:t}=e[h];if(t===null)return 0;if(i(t))return t.size;if(s.isBuffer(t))return t.length;if(t&&typeof t.getLengthSync==`function`){let e=t;return e.hasKnownLength&&e.hasKnownLength()?e.getLengthSync():null}return null},writeToStream=async(e,{body:t})=>{t===null?e.end():await m(t,e)};function readableStreamToReadable(e){let t=e.getReader();return new l({async read(){try{let{done:e,value:n}=await t.read();e?this.push(null):this.push(s.from(n))}catch(e){this.destroy(e)}},destroy(e,n){t.cancel().then(()=>n(null),n)}})}async function*blobToAsyncIterable(e){if(typeof e.stream==`function`){let t=e.stream().getReader();for(;;){let{done:e,value:n}=await t.read();if(e)break;yield n}}else yield new Uint8Array(await e.arrayBuffer())}export{clone,Body as default,extractContentType,getTotalBytes,writeToStream};
package/lib/index.spec.js CHANGED
@@ -197,6 +197,38 @@ export default async () => {
197
197
  await r.text();
198
198
  expect(r.bodyUsed).toBe(true);
199
199
  });
200
+ // `body` is an ATTRIBUTE holding one stream, not a factory. Reading it
201
+ // twice used to build two wrappers over the same flowing Readable, so
202
+ // the first reader drained everything and every later one saw an
203
+ // immediate `done` — silently, with no error. three.js' FileLoader
204
+ // reads `.body` three times in its feature guard before taking a
205
+ // reader, and so received an empty document.
206
+ await it('should return the same body stream on every read', async () => {
207
+ const r = new Response('stable body');
208
+ expect(r.body).toBe(r.body);
209
+ });
210
+ await it('should deliver the full body to a reader taken after re-reading body', async () => {
211
+ const r = new Response('the whole payload');
212
+ // Touch `.body` twice first, exactly like a feature guard does.
213
+ expect(r.body !== undefined).toBe(true);
214
+ expect(typeof r.body?.getReader).toBe('function');
215
+ const reader = r.body.getReader();
216
+ const chunks = [];
217
+ for (;;) {
218
+ const { done, value } = await reader.read();
219
+ if (done)
220
+ break;
221
+ chunks.push(value);
222
+ }
223
+ const total = chunks.reduce((n, c) => n + c.byteLength, 0);
224
+ const joined = new Uint8Array(total);
225
+ let offset = 0;
226
+ for (const c of chunks) {
227
+ joined.set(c, offset);
228
+ offset += c.byteLength;
229
+ }
230
+ expect(new TextDecoder().decode(joined)).toBe('the whole payload');
231
+ });
200
232
  await it('Response.error() should return error response', async () => {
201
233
  const r = Response.error();
202
234
  expect(r.type).toBe('error');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/fetch",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "Web and Node.js fetch module for Gjs",
5
5
  "module": "lib/esm/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -51,8 +51,8 @@
51
51
  "fetch"
52
52
  ],
53
53
  "devDependencies": {
54
- "@gjsify/cli": "^0.34.0",
55
- "@gjsify/unit": "^0.34.0",
54
+ "@gjsify/cli": "^0.36.0",
55
+ "@gjsify/unit": "^0.36.0",
56
56
  "@types/node": "^25.9.2",
57
57
  "typescript": "^6.0.3"
58
58
  },
@@ -61,11 +61,11 @@
61
61
  "@girs/gjs": "^4.1.0",
62
62
  "@girs/glib-2.0": "^4.1.0",
63
63
  "@girs/soup-3.0": "^4.1.0",
64
- "@gjsify/dom-events": "^0.34.0",
65
- "@gjsify/formdata": "^0.34.0",
66
- "@gjsify/http": "^0.34.0",
67
- "@gjsify/url": "^0.34.0",
68
- "@gjsify/utils": "^0.34.0"
64
+ "@gjsify/dom-events": "^0.36.0",
65
+ "@gjsify/formdata": "^0.36.0",
66
+ "@gjsify/http": "^0.36.0",
67
+ "@gjsify/url": "^0.36.0",
68
+ "@gjsify/utils": "^0.36.0"
69
69
  },
70
70
  "gjsify": {
71
71
  "runtimes": {