@gjsify/fetch 0.52.0 → 0.54.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/esm/index.js CHANGED
@@ -1 +1 @@
1
- import"./_virtual/_rolldown/runtime.js";import{Blob as e,File as t}from"./utils/blob-from.js";import{FetchError as n}from"./errors/fetch-error.js";import{isDomainOrSubdomain as r,isSameProtocol as i}from"./utils/is.js";import{clone as a}from"./body.js";import{AbortError as o}from"./errors/abort-error.js";import s from"./headers.js";import{parseDataUri as c}from"./utils/data-uri.js";import{resolveRootRelativeUrl as l}from"./utils/root-relative-system.js";import{isRedirect as u}from"./utils/is-redirect.js";import{Response as d}from"./response.js";import{loadSoup as f}from"./utils/soup-lazy.js";import{parseReferrerPolicyFromHeader as p}from"./utils/referrer.js";import{Request as m,getSoupRequestOptions as h}from"./request.js";import{XMLHttpRequest as g,XMLHttpRequestUpload as _}from"./xhr.js";import{URL as v}from"@gjsify/url";import y from"node:stream";import{FormData as b}from"@gjsify/formdata";import x from"@girs/glib-2.0";const S=new Set([`data:`,`http:`,`https:`,`file:`]);function rewriteFetchInput(e){if(typeof e!=`string`)return e;let t=l(e);return t!==e&&globalThis.__GJSIFY_DEBUG_FETCH===!0&&console.log(`[fetch] rewrite ${e} → ${t}`),t}async function fetch(t,l={}){t=rewriteFetchInput(t);let g=new m(t,l),{parsedURL:_,options:b}=h(g);if(!S.has(_.protocol))throw TypeError(`@gjsify/fetch cannot load ${t}. URL scheme "${_.protocol.replace(/:$/,``)}" is not supported.`);if(_.protocol===`data:`){let{buffer:e,typeFull:t}=c(g.url);return new d(Buffer.from(e),{headers:{"Content-Type":t}})}if(_.protocol===`file:`){let e=globalThis.__GJSIFY_DEBUG_FETCH===!0;e&&console.log(`[fetch] file:// ${g.url}`);try{let t=x.filename_from_uri(g.url)[0];e&&console.log(`[fetch] file:// path=${t}`);let[r,i]=x.file_get_contents(t);if(e&&console.log(`[fetch] file:// ok=${r} bytes=${i?.byteLength??`?`}`),!r)throw new n(`Failed to read file: ${t}`,`system`);let a=i,o=new Uint8Array(a.byteLength);o.set(a);let s=new d(o);return e&&console.log(`[fetch] file:// response created`),s}catch(t){let r=t instanceof Error?t:Error(String(t));throw e&&console.warn(`[fetch] file:// FAIL: ${r.message}`),new n(`request to ${g.url} failed, reason: ${r.message}`,`system`,r)}}let{signal:C}=g;if(C&&C.aborted)throw new o(`The operation was aborted.`);let w,T;try{let e=await g._send(b);w=e.readable,T=e.cancellable}catch(e){if(C&&C.aborted)throw new o(`The operation was aborted.`);let t=e instanceof Error?e:Error(String(e));throw new n(`request to ${g.url} failed, reason: ${t.message}`,`system`,t)}let abortHandler=()=>{T.cancel()};C&&C.addEventListener(`abort`,abortHandler,{once:!0});let finalize=()=>{C&&C.removeEventListener(`abort`,abortHandler)};T.connect(()=>{w.destroy(new o(`The operation was aborted.`))}),w.on(`error`,e=>{finalize()});let E=g._message,D=await f(),O=s._newFromSoupMessage(D,E),k=E.status_code,A=E.get_reason_phrase();if(u(k)){let e=O.get(`Location`),t=null;try{t=e===null?null:new v(e,g.url)}catch{if(g.redirect!==`manual`)throw finalize(),new n(`uri requested responds with an invalid redirect URL: ${e}`,`invalid-redirect`)}switch(g.redirect){case`error`:throw finalize(),new n(`uri requested responds with a redirect, redirect mode is set to error: ${g.url}`,`no-redirect`);case`manual`:break;case`follow`:{if(t===null)break;if(g.counter>=g.follow)throw finalize(),new n(`maximum redirect reached at: ${g.url}`,`max-redirect`);let e={headers:new s(g.headers),follow:g.follow,counter:g.counter+1,agent:g.agent,compress:g.compress,method:g.method,body:a(g),signal:g.signal,size:g.size,referrer:g.referrer,referrerPolicy:g.referrerPolicy};if(!r(g.url,t)||!i(g.url,t))for(let t of[`authorization`,`www-authenticate`,`cookie`,`cookie2`])e.headers.delete(t);if(k!==303&&g.body&&l.body instanceof y.Readable)throw finalize(),new n(`Cannot follow redirect with body being a readable stream`,`unsupported-redirect`);(k===303||(k===301||k===302)&&g.method===`POST`)&&(e.method=`GET`,e.body=void 0,e.headers.delete(`content-length`));let o=p(O);return o&&(e.referrerPolicy=o),finalize(),fetch(new m(t,e))}default:throw TypeError(`Redirect option '${g.redirect}' is not a valid value of RequestRedirect`)}}let j={url:g.url,status:k,statusText:A,headers:O,size:g.size,counter:g.counter,highWaterMark:g.highWaterMark},M=O.get(`Content-Encoding`);if(!g.compress||g.method===`HEAD`||M===null||k===204||k===304)return finalize(),new d(w,j);if(typeof DecompressionStream<`u`){let t=null;if(M===`gzip`||M===`x-gzip`?t=`gzip`:(M===`deflate`||M===`x-deflate`)&&(t=`deflate`),t){let n=new e([await new d(w,j).arrayBuffer()]).stream().pipeThrough(new DecompressionStream(t));return finalize(),new d(n,j)}}return finalize(),new d(w,j)}export{o as AbortError,e as Blob,n as FetchError,t as File,b as FormData,s as Headers,m as Request,d as Response,g as XMLHttpRequest,_ as XMLHttpRequestUpload,fetch as default,u as isRedirect,l as resolveRootRelativeUrl};
1
+ import"./_virtual/_rolldown/runtime.js";import{Blob as e,File as t}from"./utils/blob-from.js";import{FetchError as n}from"./errors/fetch-error.js";import{isDomainOrSubdomain as r,isSameProtocol as i}from"./utils/is.js";import{clone as a}from"./body.js";import{AbortError as o}from"./errors/abort-error.js";import s from"./headers.js";import{parseDataUri as c}from"./utils/data-uri.js";import{resolveRootRelativeUrl as l}from"./utils/root-relative-system.js";import{isRedirect as u}from"./utils/is-redirect.js";import{Response as d}from"./response.js";import{loadSoup as f}from"./utils/soup-lazy.js";import{parseReferrerPolicyFromHeader as p}from"./utils/referrer.js";import{Request as m,getSoupRequestOptions as h}from"./request.js";import{XMLHttpRequest as g,XMLHttpRequestUpload as _}from"./xhr.js";import{URL as v}from"@gjsify/url";import y from"node:stream";import{FormData as b}from"@gjsify/formdata";import x from"@girs/glib-2.0";const S=new Set([`data:`,`http:`,`https:`,`file:`]);function rewriteFetchInput(e){if(typeof e!=`string`)return e;let t=l(e);return t!==e&&globalThis.__GJSIFY_DEBUG_FETCH===!0&&console.log(`[fetch] rewrite ${e} → ${t}`),t}async function fetch(t,l={}){t=rewriteFetchInput(t);let g=new m(t,l),{parsedURL:_,options:b}=h(g);if(!S.has(_.protocol))throw TypeError(`@gjsify/fetch cannot load ${t}. URL scheme "${_.protocol.replace(/:$/,``)}" is not supported.`);if(_.protocol===`data:`){let{buffer:e,typeFull:t}=c(g.url);return new d(Buffer.from(e),{headers:{"Content-Type":t}})}if(_.protocol===`file:`){let e=globalThis.__GJSIFY_DEBUG_FETCH===!0;e&&console.log(`[fetch] file:// ${g.url}`);try{let t=x.filename_from_uri(g.url)[0];e&&console.log(`[fetch] file:// path=${t}`);let[r,i]=x.file_get_contents(t);if(e&&console.log(`[fetch] file:// ok=${r} bytes=${i?.byteLength??`?`}`),!r)throw new n(`Failed to read file: ${t}`,`system`);let a=i,o=new Uint8Array(a.byteLength);o.set(a);let s=new d(o);return e&&console.log(`[fetch] file:// response created`),s}catch(t){let r=t instanceof Error?t:Error(String(t));throw e&&console.warn(`[fetch] file:// FAIL: ${r.message}`),new n(`request to ${g.url} failed, reason: ${r.message}`,`system`,r)}}let{signal:C}=g;if(C&&C.aborted)throw new o(`The operation was aborted.`);let w,T;try{let e=await g._send(b);w=e.readable,T=e.cancellable}catch(e){if(C&&C.aborted)throw new o(`The operation was aborted.`);let t=e instanceof Error?e:Error(String(e));throw new n(`request to ${g.url} failed, reason: ${t.message}`,`system`,t)}let abortHandler=()=>{T.cancel()};C&&C.addEventListener(`abort`,abortHandler,{once:!0});let finalize=()=>{C&&C.removeEventListener(`abort`,abortHandler)};T.connect(()=>{w.destroy(new o(`The operation was aborted.`))}),w.on(`error`,e=>{finalize()});let E=g._message,D=await f(),O=s._newFromSoupMessage(D,E),k=E.status_code,A=E.get_reason_phrase();if(u(k)){let e=O.get(`Location`),t=null;try{t=e===null?null:new v(e,g.url)}catch{if(g.redirect!==`manual`)throw w.destroy(),finalize(),new n(`uri requested responds with an invalid redirect URL: ${e}`,`invalid-redirect`)}switch(g.redirect){case`error`:throw w.destroy(),finalize(),new n(`uri requested responds with a redirect, redirect mode is set to error: ${g.url}`,`no-redirect`);case`manual`:break;case`follow`:{if(t===null)break;if(g.counter>=g.follow)throw w.destroy(),finalize(),new n(`maximum redirect reached at: ${g.url}`,`max-redirect`);let e={headers:new s(g.headers),follow:g.follow,counter:g.counter+1,agent:g.agent,compress:g.compress,method:g.method,body:a(g),signal:g.signal,size:g.size,referrer:g.referrer,referrerPolicy:g.referrerPolicy};if(!r(g.url,t)||!i(g.url,t))for(let t of[`authorization`,`www-authenticate`,`cookie`,`cookie2`])e.headers.delete(t);if(k!==303&&g.body&&l.body instanceof y.Readable)throw w.destroy(),finalize(),new n(`Cannot follow redirect with body being a readable stream`,`unsupported-redirect`);(k===303||(k===301||k===302)&&g.method===`POST`)&&(e.method=`GET`,e.body=void 0,e.headers.delete(`content-length`));let o=p(O);return o&&(e.referrerPolicy=o),await new Promise(e=>{w.once(`close`,()=>e()),w.resume()}),finalize(),fetch(new m(t,e))}default:throw w.destroy(),finalize(),TypeError(`Redirect option '${g.redirect}' is not a valid value of RequestRedirect`)}}let j={url:g.url,status:k,statusText:A,headers:O,size:g.size,counter:g.counter,highWaterMark:g.highWaterMark},M=O.get(`Content-Encoding`);if(!g.compress||g.method===`HEAD`||M===null||k===204||k===304)return finalize(),new d(w,j);if(typeof DecompressionStream<`u`){let t=null;if(M===`gzip`||M===`x-gzip`?t=`gzip`:(M===`deflate`||M===`x-deflate`)&&(t=`deflate`),t){let n=new e([await new d(w,j).arrayBuffer()]).stream().pipeThrough(new DecompressionStream(t));return finalize(),new d(n,j)}}return finalize(),new d(w,j)}export{o as AbortError,e as Blob,n as FetchError,t as File,b as FormData,s as Headers,m as Request,d as Response,g as XMLHttpRequest,_ as XMLHttpRequestUpload,fetch as default,u as isRedirect,l as resolveRootRelativeUrl};
@@ -1 +1 @@
1
- import"../_virtual/_rolldown/runtime.js";import{Readable as e}from"node:stream";import t from"@girs/glib-2.0";import{inputStreamAsyncIterator as n}from"@gjsify/utils";async function soupSendAsync(e,n,r=t.PRIORITY_DEFAULT,i=null){return new Promise((t,a)=>{e.send_async(n,r,i,(n,r)=>{try{t(e.send_finish(r))}catch(e){a(e)}})})}function inputStreamToReadable(t,r={}){return e.from(n(t),r)}export{inputStreamToReadable,soupSendAsync};
1
+ import"../_virtual/_rolldown/runtime.js";import{Readable as e}from"node:stream";import t from"@girs/glib-2.0";import n from"@girs/gio-2.0";import{inputStreamAsyncIterator as r}from"@gjsify/utils";async function soupSendAsync(e,r,i=t.PRIORITY_DEFAULT,a=null){return new Promise((t,o)=>{let s=!1,onCancel=()=>{s||(s=!0,o(new n.IOErrorEnum({code:n.IOErrorEnum.CANCELLED,message:`Operation was cancelled`})))},c=a?a.connect(onCancel):0;s||e.send_async(r,i,a,(n,r)=>{c&&a.disconnect(c);let i;try{i=e.send_finish(r)}catch(e){s||(s=!0,o(e));return}if(s){closeInputStream(i);return}s=!0,t(i)})})}function closeInputStream(e){return new Promise(n=>{e.close_async(t.PRIORITY_DEFAULT,null,(t,r)=>{try{e.close_finish(r)}catch{}n()})})}function inputStreamToReadable(i,a={}){let o=new n.Cancellable,s=null,close=()=>s||(i.has_pending()?Promise.resolve():(s=closeInputStream(i),s));async function*chunks(){try{yield*r(i,4096,t.PRIORITY_DEFAULT,o)}finally{await close()}}let c=e.from(chunks(),a);return c.once(`close`,()=>{i.has_pending()?o.cancel():close()}),c}export{inputStreamToReadable,soupSendAsync};
package/lib/index.js CHANGED
@@ -155,6 +155,11 @@ export default async function fetch(url, init = {}) {
155
155
  const statusMessage = message.get_reason_phrase();
156
156
  // HTTP fetch step 5 — handle redirects
157
157
  if (isRedirect(statusCode)) {
158
+ // A redirect response never reaches the caller, so nothing else will
159
+ // consume or close its body — and an unclosed Soup body stream pins its
160
+ // connection until GC (see inputStreamToReadable). Drain it when
161
+ // following, so the connection is back in the pool before the next hop
162
+ // (usually to the same host) asks for one; destroy it on every error exit.
158
163
  const location = headers.get('Location');
159
164
  let locationURL = null;
160
165
  try {
@@ -162,12 +167,14 @@ export default async function fetch(url, init = {}) {
162
167
  }
163
168
  catch {
164
169
  if (request.redirect !== 'manual') {
170
+ readable.destroy();
165
171
  finalize();
166
172
  throw new FetchError(`uri requested responds with an invalid redirect URL: ${location}`, 'invalid-redirect');
167
173
  }
168
174
  }
169
175
  switch (request.redirect) {
170
176
  case 'error':
177
+ readable.destroy();
171
178
  finalize();
172
179
  throw new FetchError(`uri requested responds with a redirect, redirect mode is set to error: ${request.url}`, 'no-redirect');
173
180
  case 'manual':
@@ -178,6 +185,7 @@ export default async function fetch(url, init = {}) {
178
185
  break;
179
186
  }
180
187
  if (request.counter >= request.follow) {
188
+ readable.destroy();
181
189
  finalize();
182
190
  throw new FetchError(`maximum redirect reached at: ${request.url}`, 'max-redirect');
183
191
  }
@@ -202,6 +210,7 @@ export default async function fetch(url, init = {}) {
202
210
  }
203
211
  // Cannot follow redirect with body being a readable stream
204
212
  if (statusCode !== 303 && request.body && init.body instanceof Stream.Readable) {
213
+ readable.destroy();
205
214
  finalize();
206
215
  throw new FetchError('Cannot follow redirect with body being a readable stream', 'unsupported-redirect');
207
216
  }
@@ -216,10 +225,17 @@ export default async function fetch(url, init = {}) {
216
225
  if (responseReferrerPolicy) {
217
226
  requestOptions.referrerPolicy = responseReferrerPolicy;
218
227
  }
228
+ // 'close', not 'end': it also fires when an abort destroys the body mid-drain.
229
+ await new Promise((resolve) => {
230
+ readable.once('close', () => resolve());
231
+ readable.resume();
232
+ });
219
233
  finalize();
220
234
  return fetch(new Request(locationURL, requestOptions));
221
235
  }
222
236
  default:
237
+ readable.destroy();
238
+ finalize();
223
239
  throw new TypeError(`Redirect option '${request.redirect}' is not a valid value of RequestRedirect`);
224
240
  }
225
241
  }
@@ -138,6 +138,178 @@ export default async () => {
138
138
  expect(stderr.includes('libsoup-CRITICAL')).toBe(false);
139
139
  });
140
140
  });
141
+ // Regression: libsoup 3 hands a connection back to the pool only when the
142
+ // response body stream is CLOSED — reading it to EOF merely marks the body
143
+ // done. @gjsify/fetch read bodies to EOF and never closed the stream, so
144
+ // every fetch pinned its connection IN_USE until SpiderMonkey happened to
145
+ // finalize the stream. Keep-alive reuse never happened, and once the
146
+ // shared session's `max-conns` (64) were pinned against servers that had
147
+ // since gone away, the next fetch queued forever. socket.io's polling
148
+ // transport hit that wall ~30 tests into its integration suite on macOS,
149
+ // where GC ran too rarely to rescue it.
150
+ await describe('@gjsify/fetch — body streams release their pooled connection', async () => {
151
+ const startServer = () => {
152
+ const ports = [];
153
+ const server = new Soup.Server({});
154
+ server.add_handler(null, (_s, msg) => {
155
+ ports.push(msg.get_remote_address().get_port());
156
+ msg.set_status(200, null);
157
+ msg.set_response('text/plain', Soup.MemoryUse.COPY, new TextEncoder().encode('ok'));
158
+ });
159
+ server.listen_local(0, Soup.ServerListenOptions.IPV4_ONLY);
160
+ return { server, ports, base: server.get_uris()[0].to_string() };
161
+ };
162
+ await it('reuses the keep-alive connection once a body has been read', async () => {
163
+ const { server, ports, base } = startServer();
164
+ // fetch defaults to `Connection: close` (node-fetch heritage), so
165
+ // opt into keep-alive to observe the connection going back to the pool.
166
+ const init = { headers: { Connection: 'keep-alive' } };
167
+ try {
168
+ await (await fetchFn(base, init)).text();
169
+ await (await fetchFn(base, init)).arrayBuffer();
170
+ await (await fetchFn(base, init)).text();
171
+ expect(ports.length).toBe(3);
172
+ // One client port = one connection, handed back and reused.
173
+ expect(new Set(ports).size).toBe(1);
174
+ }
175
+ finally {
176
+ server.disconnect();
177
+ }
178
+ });
179
+ await it('does not exhaust the pool against servers that have gone away', async () => {
180
+ // More servers than the shared session's default max-conns (64):
181
+ // each leaves one idle keep-alive connection behind. Idle ones are
182
+ // reclaimable; pinned ones made fetch #65 wait forever.
183
+ let completed = 0;
184
+ for (let i = 0; i < 70; i++) {
185
+ const { server, base } = startServer();
186
+ const ctrl = new AbortController();
187
+ const timer = setTimeout(() => ctrl.abort(), 2000);
188
+ try {
189
+ await (await fetchFn(base, { signal: ctrl.signal })).text();
190
+ completed++;
191
+ }
192
+ catch {
193
+ break;
194
+ }
195
+ finally {
196
+ clearTimeout(timer);
197
+ server.disconnect();
198
+ }
199
+ }
200
+ expect(completed).toBe(70);
201
+ });
202
+ await it('drains a followed redirect so the next hop reuses its connection', async () => {
203
+ const ports = [];
204
+ const server = new Soup.Server({});
205
+ server.add_handler(null, (_s, msg) => {
206
+ ports.push(msg.get_remote_address().get_port());
207
+ if (msg.get_uri().get_path() === '/moved') {
208
+ msg.set_redirect(307, '/target');
209
+ // A non-empty body: libsoup completes an empty one without a stream close.
210
+ msg.set_response('text/plain', Soup.MemoryUse.COPY, new TextEncoder().encode('moved'));
211
+ }
212
+ else {
213
+ msg.set_status(200, null);
214
+ msg.set_response('text/plain', Soup.MemoryUse.COPY, new TextEncoder().encode('ok'));
215
+ }
216
+ });
217
+ server.listen_local(0, Soup.ServerListenOptions.IPV4_ONLY);
218
+ const base = server.get_uris()[0].to_string();
219
+ try {
220
+ // libsoup follows safe-method redirects itself; a 307 on PUT is
221
+ // left to fetch's own redirect step, the path under test.
222
+ const res = await fetchFn(`${base}moved`, { method: 'PUT', headers: { Connection: 'keep-alive' } });
223
+ expect(await res.text()).toBe('ok');
224
+ expect(ports.length).toBe(2);
225
+ // Nobody reads a redirect's body; left open it pinned its connection.
226
+ expect(new Set(ports).size).toBe(1);
227
+ }
228
+ finally {
229
+ server.disconnect();
230
+ }
231
+ });
232
+ // A body read parked on a server that has gone quiet (long-poll, SSE)
233
+ // kept the stream open past body.cancel(): close() cannot run while
234
+ // a read is pending, so the connection stayed pinned. Cancel one
235
+ // such body per per-host slot (DEFAULT_MAX_CONNS_PER_HOST = 16);
236
+ // pinned, they leave no slot for the request after them.
237
+ await it('releases the connection when a body is cancelled mid-stream', async () => {
238
+ const server = new Soup.Server({});
239
+ server.add_handler(null, (_s, msg) => {
240
+ msg.set_status(200, null);
241
+ if (msg.get_uri().get_path() === '/stream') {
242
+ msg.get_response_headers().set_encoding(Soup.Encoding.CHUNKED);
243
+ // No complete(): the response stays open with nothing more to read.
244
+ msg.get_response_body().append(new TextEncoder().encode('first'));
245
+ }
246
+ else {
247
+ msg.set_response('text/plain', Soup.MemoryUse.COPY, new TextEncoder().encode('ok'));
248
+ }
249
+ });
250
+ server.listen_local(0, Soup.ServerListenOptions.IPV4_ONLY);
251
+ const base = server.get_uris()[0].to_string();
252
+ try {
253
+ for (let i = 0; i < 16; i++) {
254
+ const res = await fetchFn(`${base}stream`);
255
+ const reader = res.body.getReader();
256
+ const first = await reader.read();
257
+ expect(new TextDecoder().decode(first.value)).toBe('first');
258
+ await reader.cancel();
259
+ }
260
+ const ctrl = new AbortController();
261
+ const timer = setTimeout(() => ctrl.abort(), 2000);
262
+ try {
263
+ expect(await (await fetchFn(base, { signal: ctrl.signal })).text()).toBe('ok');
264
+ }
265
+ finally {
266
+ clearTimeout(timer);
267
+ }
268
+ }
269
+ finally {
270
+ server.disconnect();
271
+ }
272
+ });
273
+ // libsoup does not watch the cancellable of a message still queued
274
+ // for a connection slot, so an abort there used to hang until some
275
+ // other request freed a slot.
276
+ await it('rejects an aborted fetch that is still queued for a connection', async () => {
277
+ const paused = [];
278
+ const server = new Soup.Server({});
279
+ server.add_handler(null, (_s, msg) => {
280
+ msg.pause();
281
+ paused.push(msg);
282
+ });
283
+ server.listen_local(0, Soup.ServerListenOptions.IPV4_ONLY);
284
+ const base = server.get_uris()[0].to_string();
285
+ // Fill every per-host slot (DEFAULT_MAX_CONNS_PER_HOST) with a hung request.
286
+ const holders = Array.from({ length: 16 }, () => new AbortController());
287
+ const held = holders.map((c) => fetchFn(base, { signal: c.signal }).catch(() => undefined));
288
+ try {
289
+ const deadline = Date.now() + 2000;
290
+ while (paused.length < holders.length && Date.now() < deadline) {
291
+ await new Promise((r) => setTimeout(r, 20));
292
+ }
293
+ expect(paused.length).toBe(holders.length);
294
+ const ctrl = new AbortController();
295
+ const queued = fetchFn(base, { signal: ctrl.signal }).then(() => 'resolved', (err) => err.name);
296
+ setTimeout(() => ctrl.abort(), 50);
297
+ const outcome = await Promise.race([
298
+ queued,
299
+ new Promise((r) => setTimeout(() => r('still queued'), 2000)),
300
+ ]);
301
+ expect(outcome).toBe('AbortError');
302
+ }
303
+ finally {
304
+ for (const c of holders)
305
+ c.abort();
306
+ await Promise.all(held);
307
+ for (const msg of paused)
308
+ msg.unpause();
309
+ server.disconnect();
310
+ }
311
+ });
312
+ });
141
313
  // Regression: @gjsify/fetch re-parsed the already-encoded request URL
142
314
  // with `GLib.UriFlags.NONE`, which DECODES a second time — collapsing an
143
315
  // escaped `%2F` inside a path segment back to a literal `/`. That sent
@@ -12,10 +12,11 @@
12
12
  // GJS-only (`.gjs.spec.ts`): the body runs under `on('Gjs', …)`, a no-op on Node. Soup/GLib/Gio are
13
13
  // read from the GJS bootstrap `imports` (same idiom as soup-session.gjs.spec.ts) so the Node bundle
14
14
  // never resolves `gi://*`. Request/fetch are read from `globalThis` (installed by
15
- // `@gjsify/fetch/register`, pulled in by test.mts). `@gjsify/abort-controller` is pure-TS
16
- // cross-runtime, so its static import is safe on both legs.
15
+ // `@gjsify/fetch/register`, pulled in by test.mts), and so is AbortController/AbortSignal
16
+ // (`@gjsify/abort-controller/register`): a workspace import here would be compiled by this
17
+ // package's `build:types`, which a cold `@gjsify/cli --with-dependencies` sweep may run before
18
+ // abort-controller's (held by scripts/check-build-infra-order.mjs, incident #1775).
17
19
  import { describe, it, expect, on } from '@gjsify/unit';
18
- import { AbortController, AbortSignal } from '@gjsify/abort-controller';
19
20
  // A REAL self-signed cert+key (RSA-2048, CN=localhost, SAN DNS:localhost + IP:127.0.0.1, 100y) so a
20
21
  // live TLS handshake against the local Soup.Server actually completes. Embedded inline to stay
21
22
  // hermetic (no fixtures) — same approach as packages/node/tls TEST_CERT_AND_KEY. Regenerate with:
@@ -1,12 +1,28 @@
1
- import type Gio from '@girs/gio-2.0';
1
+ import Gio from '@girs/gio-2.0';
2
2
  import type Soup from '@girs/soup-3.0';
3
3
  import { Readable } from 'node:stream';
4
4
  import type { ReadableOptions } from 'node:stream';
5
5
  /**
6
6
  * Promise wrapper around `Soup.Session.send_async` / `send_finish`.
7
+ *
8
+ * Rejects as soon as `cancellable` fires instead of waiting for libsoup's
9
+ * callback: a message still QUEUED for a connection slot (the session is at
10
+ * `max-conns` / `max-conns-per-host`) sits in libsoup's STARTING state, which
11
+ * never looks at its cancellable — the cancellation only lands once some other
12
+ * request frees a slot, so an aborted fetch would hang until then. The message
13
+ * stays queued inside libsoup and fails with CANCELLED when it is dequeued; a
14
+ * stream that wins the race anyway is closed so it cannot pin its connection.
7
15
  */
8
16
  export declare function soupSendAsync(session: Soup.Session, msg: Soup.Message, ioPriority?: number, cancellable?: Gio.Cancellable | null): Promise<Gio.InputStream>;
9
17
  /**
10
- * Converts a `Gio.InputStream` to a Node.js `Readable` stream.
18
+ * Converts a `Gio.InputStream` to a Node.js `Readable` stream, closing the
19
+ * input stream once the body is consumed or the Readable is destroyed.
20
+ *
21
+ * The close is load-bearing for Soup response bodies: libsoup 3 returns the
22
+ * connection to the session's pool only when the body stream is CLOSED —
23
+ * reading to EOF merely marks the body done. Left open, each response pins its
24
+ * connection IN_USE until the stream happens to be GC-finalized; keep-alive
25
+ * reuse never happens, and once `max-conns` are pinned the next request queues
26
+ * forever (socket.io's polling transport stalled on exactly that).
11
27
  */
12
28
  export declare function inputStreamToReadable(inputStream: Gio.InputStream, options?: ReadableOptions): Readable;
@@ -1,25 +1,122 @@
1
+ import Gio from '@girs/gio-2.0';
1
2
  import GLib from '@girs/glib-2.0';
2
3
  import { Readable } from 'node:stream';
3
4
  import { inputStreamAsyncIterator } from '@gjsify/utils';
4
5
  /**
5
6
  * Promise wrapper around `Soup.Session.send_async` / `send_finish`.
7
+ *
8
+ * Rejects as soon as `cancellable` fires instead of waiting for libsoup's
9
+ * callback: a message still QUEUED for a connection slot (the session is at
10
+ * `max-conns` / `max-conns-per-host`) sits in libsoup's STARTING state, which
11
+ * never looks at its cancellable — the cancellation only lands once some other
12
+ * request frees a slot, so an aborted fetch would hang until then. The message
13
+ * stays queued inside libsoup and fails with CANCELLED when it is dequeued; a
14
+ * stream that wins the race anyway is closed so it cannot pin its connection.
6
15
  */
7
16
  export async function soupSendAsync(session, msg, ioPriority = GLib.PRIORITY_DEFAULT, cancellable = null) {
8
17
  return new Promise((resolve, reject) => {
18
+ let settled = false;
19
+ const onCancel = () => {
20
+ if (settled)
21
+ return;
22
+ settled = true;
23
+ reject(new Gio.IOErrorEnum({ code: Gio.IOErrorEnum.CANCELLED, message: 'Operation was cancelled' }));
24
+ };
25
+ // g_cancellable_connect runs the handler at once when already cancelled.
26
+ const handlerId = cancellable ? cancellable.connect(onCancel) : 0;
27
+ if (settled)
28
+ return;
9
29
  session.send_async(msg, ioPriority, cancellable, (_self, asyncRes) => {
30
+ // Not from inside the cancel handler, so disconnecting cannot deadlock.
31
+ if (handlerId)
32
+ cancellable.disconnect(handlerId);
33
+ let inputStream;
10
34
  try {
11
- const inputStream = session.send_finish(asyncRes);
12
- resolve(inputStream);
35
+ inputStream = session.send_finish(asyncRes);
13
36
  }
14
37
  catch (error) {
15
- reject(error);
38
+ if (!settled) {
39
+ settled = true;
40
+ reject(error);
41
+ }
42
+ return;
16
43
  }
44
+ if (settled) {
45
+ void closeInputStream(inputStream);
46
+ return;
47
+ }
48
+ settled = true;
49
+ resolve(inputStream);
50
+ });
51
+ });
52
+ }
53
+ /**
54
+ * Close a `Gio.InputStream` without blocking the main loop. Resolves once the
55
+ * close has finished; never rejects — a close failure leaves nothing for the
56
+ * caller to recover, and the stream is unusable either way.
57
+ */
58
+ function closeInputStream(inputStream) {
59
+ return new Promise((resolve) => {
60
+ inputStream.close_async(GLib.PRIORITY_DEFAULT, null, (_self, res) => {
61
+ try {
62
+ inputStream.close_finish(res);
63
+ }
64
+ catch {
65
+ /* already closed, or the connection died — nothing to release */
66
+ }
67
+ resolve();
17
68
  });
18
69
  });
19
70
  }
20
71
  /**
21
- * Converts a `Gio.InputStream` to a Node.js `Readable` stream.
72
+ * Converts a `Gio.InputStream` to a Node.js `Readable` stream, closing the
73
+ * input stream once the body is consumed or the Readable is destroyed.
74
+ *
75
+ * The close is load-bearing for Soup response bodies: libsoup 3 returns the
76
+ * connection to the session's pool only when the body stream is CLOSED —
77
+ * reading to EOF merely marks the body done. Left open, each response pins its
78
+ * connection IN_USE until the stream happens to be GC-finalized; keep-alive
79
+ * reuse never happens, and once `max-conns` are pinned the next request queues
80
+ * forever (socket.io's polling transport stalled on exactly that).
22
81
  */
23
82
  export function inputStreamToReadable(inputStream, options = {}) {
24
- return Readable.from(inputStreamAsyncIterator(inputStream), options);
83
+ // Owned here, not by the caller: it only ever cancels an in-flight body
84
+ // read of a Readable that has already been destroyed.
85
+ const readCancellable = new Gio.Cancellable();
86
+ let closing = null;
87
+ const close = () => {
88
+ if (closing)
89
+ return closing;
90
+ // A read still in flight owns the stream (close would fail with
91
+ // G_IO_ERROR_PENDING); the generator's `finally` closes it once that
92
+ // read settles.
93
+ if (inputStream.has_pending())
94
+ return Promise.resolve();
95
+ closing = closeInputStream(inputStream);
96
+ return closing;
97
+ };
98
+ async function* chunks() {
99
+ try {
100
+ yield* inputStreamAsyncIterator(inputStream, 4096, GLib.PRIORITY_DEFAULT, readCancellable);
101
+ }
102
+ finally {
103
+ // Awaited, so 'end' (and with it text()/arrayBuffer()) is only
104
+ // observed after the connection is back in the pool — a follow-up
105
+ // request to the same host can then reuse it.
106
+ await close();
107
+ }
108
+ }
109
+ const readable = Readable.from(chunks(), options);
110
+ readable.once('close', () => {
111
+ // A destroyed Readable (abort, body.cancel(), consumer gave up) may
112
+ // never resume the generator, so its `finally` would not run — close
113
+ // from here. A read still waiting on an idle server (long-poll, SSE)
114
+ // would hold the stream forever: cancel it, and the generator's
115
+ // `finally` closes the stream once the cancelled read settles.
116
+ if (inputStream.has_pending())
117
+ readCancellable.cancel();
118
+ else
119
+ void close();
120
+ });
121
+ return readable;
25
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/fetch",
3
- "version": "0.52.0",
3
+ "version": "0.54.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,9 @@
51
51
  "fetch"
52
52
  ],
53
53
  "devDependencies": {
54
- "@gjsify/cli": "^0.52.0",
55
- "@gjsify/unit": "^0.52.0",
54
+ "@gjsify/abort-controller": "^0.54.0",
55
+ "@gjsify/cli": "^0.54.0",
56
+ "@gjsify/unit": "^0.54.0",
56
57
  "@types/node": "^25.9.2",
57
58
  "typescript": "^6.0.3"
58
59
  },
@@ -61,11 +62,11 @@
61
62
  "@girs/gjs": "5.4.0",
62
63
  "@girs/glib-2.0": "5.4.0",
63
64
  "@girs/soup-3.0": "5.4.0",
64
- "@gjsify/dom-events": "^0.52.0",
65
- "@gjsify/formdata": "^0.52.0",
66
- "@gjsify/http": "^0.52.0",
67
- "@gjsify/url": "^0.52.0",
68
- "@gjsify/utils": "^0.52.0"
65
+ "@gjsify/dom-events": "^0.54.0",
66
+ "@gjsify/formdata": "^0.54.0",
67
+ "@gjsify/http": "^0.54.0",
68
+ "@gjsify/url": "^0.54.0",
69
+ "@gjsify/utils": "^0.54.0"
69
70
  },
70
71
  "gjsify": {
71
72
  "runtimes": {