@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 +1 -1
- package/lib/esm/utils/soup-helpers.js +1 -1
- package/lib/index.js +16 -0
- package/lib/soup-session.gjs.spec.js +172 -0
- package/lib/tls-abort.gjs.spec.js +4 -3
- package/lib/utils/soup-helpers.d.ts +18 -2
- package/lib/utils/soup-helpers.js +102 -5
- package/package.json +9 -8
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
|
|
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)
|
|
16
|
-
//
|
|
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
|
|
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
|
-
|
|
12
|
-
resolve(inputStream);
|
|
35
|
+
inputStream = session.send_finish(asyncRes);
|
|
13
36
|
}
|
|
14
37
|
catch (error) {
|
|
15
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
55
|
-
"@gjsify/
|
|
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.
|
|
65
|
-
"@gjsify/formdata": "^0.
|
|
66
|
-
"@gjsify/http": "^0.
|
|
67
|
-
"@gjsify/url": "^0.
|
|
68
|
-
"@gjsify/utils": "^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": {
|