@gjsify/fetch 0.16.9 → 0.18.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/request.js +1 -1
- package/lib/index.js +5 -0
- package/lib/request.d.ts +2 -0
- package/lib/request.js +38 -1
- package/lib/tls-abort.gjs.spec.d.ts +2 -0
- package/lib/tls-abort.gjs.spec.js +263 -0
- package/package.json +8 -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 o from"./headers.js";import{parseDataUri as s}from"./utils/data-uri.js";import{isRedirect as c}from"./utils/is-redirect.js";import{Response as l}from"./response.js";import{parseReferrerPolicyFromHeader as u}from"./utils/referrer.js";import{Request as d,getSoupRequestOptions as f}from"./request.js";import{AbortError as p}from"./errors/abort-error.js";import{XMLHttpRequest as m,XMLHttpRequestUpload as h}from"./xhr.js";import{URL as g}from"@gjsify/url";import _ from"node:stream";import{FormData as v}from"@gjsify/formdata";import y from"@girs/glib-2.0";const b=new Set([`data:`,`http:`,`https:`,`file:`]);function rewriteRootRelativeUrl(e){if(typeof e!=`string`||!e.startsWith(`/`)||e.startsWith(`//`))return e;let t=globalThis,n=t.__GJSIFY_DEBUG_FETCH===!0;try{let r=t.imports?.system?.programPath??t.imports?.system?.programInvocationName??``;if(!r)return e;let i=`file://${y.path_get_dirname(r)}${e}`;return n&&console.log(`[fetch] rewrite ${e} → ${i}`),i}catch(t){return n&&console.warn(`[fetch] rewrite FAILED: ${t instanceof Error?t.message:String(t)}`),e}}async function fetch(t,m={}){t=rewriteRootRelativeUrl(t);let h=new d(t,m),{parsedURL:v,options:x}=f(h);if(!b.has(v.protocol))throw TypeError(`@gjsify/fetch cannot load ${t}. URL scheme "${v.protocol.replace(/:$/,``)}" is not supported.`);if(v.protocol===`data:`){let{buffer:e,typeFull:t}=s(h.url);return new l(Buffer.from(e),{headers:{"Content-Type":t}})}if(v.protocol===`file:`){let e=globalThis.__GJSIFY_DEBUG_FETCH===!0;e&&console.log(`[fetch] file:// ${h.url}`);try{let t=y.filename_from_uri(h.url)[0];e&&console.log(`[fetch] file:// path=${t}`);let[r,i]=y.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 l(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 ${h.url} failed, reason: ${r.message}`,`system`,r)}}let{signal:S}=h;if(S&&S.aborted)throw new p(`The operation was aborted.`);let C,w;try{let e=await h._send(x);C=e.readable,w=e.cancellable}catch(e){let t=e instanceof Error?e:Error(String(e));throw new n(`request to ${h.url} failed, reason: ${t.message}`,`system`,t)}let abortHandler=()=>{w.cancel()};S&&S.addEventListener(`abort`,abortHandler,{once:!0});let finalize=()=>{S&&S.removeEventListener(`abort`,abortHandler)};w.connect(()=>{C.destroy(new p(`The operation was aborted.`))}),C.on(`error`,e=>{finalize()});let T=h._message,E=o._newFromSoupMessage(T),D=T.status_code,O=T.get_reason_phrase();if(c(D)){let e=E.get(`Location`),t=null;try{t=e===null?null:new g(e,h.url)}catch{if(h.redirect!==`manual`)throw finalize(),new n(`uri requested responds with an invalid redirect URL: ${e}`,`invalid-redirect`)}switch(h.redirect){case`error`:throw finalize(),new n(`uri requested responds with a redirect, redirect mode is set to error: ${h.url}`,`no-redirect`);case`manual`:break;case`follow`:{if(t===null)break;if(h.counter>=h.follow)throw finalize(),new n(`maximum redirect reached at: ${h.url}`,`max-redirect`);let e={headers:new o(h.headers),follow:h.follow,counter:h.counter+1,agent:h.agent,compress:h.compress,method:h.method,body:a(h),signal:h.signal,size:h.size,referrer:h.referrer,referrerPolicy:h.referrerPolicy};if(!r(h.url,t)||!i(h.url,t))for(let t of[`authorization`,`www-authenticate`,`cookie`,`cookie2`])e.headers.delete(t);if(D!==303&&h.body&&m.body instanceof _.Readable)throw finalize(),new n(`Cannot follow redirect with body being a readable stream`,`unsupported-redirect`);(D===303||(D===301||D===302)&&h.method===`POST`)&&(e.method=`GET`,e.body=void 0,e.headers.delete(`content-length`));let s=u(E);return s&&(e.referrerPolicy=s),finalize(),fetch(new d(t,e))}default:throw TypeError(`Redirect option '${h.redirect}' is not a valid value of RequestRedirect`)}}let k={url:h.url,status:D,statusText:O,headers:E,size:h.size,counter:h.counter,highWaterMark:h.highWaterMark},A=E.get(`Content-Encoding`);if(!h.compress||h.method===`HEAD`||A===null||D===204||D===304)return finalize(),new l(C,k);if(typeof DecompressionStream<`u`){let t=null;if(A===`gzip`||A===`x-gzip`?t=`gzip`:(A===`deflate`||A===`x-deflate`)&&(t=`deflate`),t){let n=new e([await new l(C,k).arrayBuffer()]).stream().pipeThrough(new DecompressionStream(t));return finalize(),new l(n,k)}}return finalize(),new l(C,k)}export{p as AbortError,e as Blob,n as FetchError,t as File,v as FormData,o as Headers,d as Request,l as Response,m as XMLHttpRequest,h as XMLHttpRequestUpload,fetch as default,c as isRedirect};
|
|
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 o from"./headers.js";import{parseDataUri as s}from"./utils/data-uri.js";import{isRedirect as c}from"./utils/is-redirect.js";import{Response as l}from"./response.js";import{parseReferrerPolicyFromHeader as u}from"./utils/referrer.js";import{Request as d,getSoupRequestOptions as f}from"./request.js";import{AbortError as p}from"./errors/abort-error.js";import{XMLHttpRequest as m,XMLHttpRequestUpload as h}from"./xhr.js";import{URL as g}from"@gjsify/url";import _ from"node:stream";import{FormData as v}from"@gjsify/formdata";import y from"@girs/glib-2.0";const b=new Set([`data:`,`http:`,`https:`,`file:`]);function rewriteRootRelativeUrl(e){if(typeof e!=`string`||!e.startsWith(`/`)||e.startsWith(`//`))return e;let t=globalThis,n=t.__GJSIFY_DEBUG_FETCH===!0;try{let r=t.imports?.system?.programPath??t.imports?.system?.programInvocationName??``;if(!r)return e;let i=`file://${y.path_get_dirname(r)}${e}`;return n&&console.log(`[fetch] rewrite ${e} → ${i}`),i}catch(t){return n&&console.warn(`[fetch] rewrite FAILED: ${t instanceof Error?t.message:String(t)}`),e}}async function fetch(t,m={}){t=rewriteRootRelativeUrl(t);let h=new d(t,m),{parsedURL:v,options:x}=f(h);if(!b.has(v.protocol))throw TypeError(`@gjsify/fetch cannot load ${t}. URL scheme "${v.protocol.replace(/:$/,``)}" is not supported.`);if(v.protocol===`data:`){let{buffer:e,typeFull:t}=s(h.url);return new l(Buffer.from(e),{headers:{"Content-Type":t}})}if(v.protocol===`file:`){let e=globalThis.__GJSIFY_DEBUG_FETCH===!0;e&&console.log(`[fetch] file:// ${h.url}`);try{let t=y.filename_from_uri(h.url)[0];e&&console.log(`[fetch] file:// path=${t}`);let[r,i]=y.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 l(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 ${h.url} failed, reason: ${r.message}`,`system`,r)}}let{signal:S}=h;if(S&&S.aborted)throw new p(`The operation was aborted.`);let C,w;try{let e=await h._send(x);C=e.readable,w=e.cancellable}catch(e){if(S&&S.aborted)throw new p(`The operation was aborted.`);let t=e instanceof Error?e:Error(String(e));throw new n(`request to ${h.url} failed, reason: ${t.message}`,`system`,t)}let abortHandler=()=>{w.cancel()};S&&S.addEventListener(`abort`,abortHandler,{once:!0});let finalize=()=>{S&&S.removeEventListener(`abort`,abortHandler)};w.connect(()=>{C.destroy(new p(`The operation was aborted.`))}),C.on(`error`,e=>{finalize()});let T=h._message,E=o._newFromSoupMessage(T),D=T.status_code,O=T.get_reason_phrase();if(c(D)){let e=E.get(`Location`),t=null;try{t=e===null?null:new g(e,h.url)}catch{if(h.redirect!==`manual`)throw finalize(),new n(`uri requested responds with an invalid redirect URL: ${e}`,`invalid-redirect`)}switch(h.redirect){case`error`:throw finalize(),new n(`uri requested responds with a redirect, redirect mode is set to error: ${h.url}`,`no-redirect`);case`manual`:break;case`follow`:{if(t===null)break;if(h.counter>=h.follow)throw finalize(),new n(`maximum redirect reached at: ${h.url}`,`max-redirect`);let e={headers:new o(h.headers),follow:h.follow,counter:h.counter+1,agent:h.agent,compress:h.compress,method:h.method,body:a(h),signal:h.signal,size:h.size,referrer:h.referrer,referrerPolicy:h.referrerPolicy};if(!r(h.url,t)||!i(h.url,t))for(let t of[`authorization`,`www-authenticate`,`cookie`,`cookie2`])e.headers.delete(t);if(D!==303&&h.body&&m.body instanceof _.Readable)throw finalize(),new n(`Cannot follow redirect with body being a readable stream`,`unsupported-redirect`);(D===303||(D===301||D===302)&&h.method===`POST`)&&(e.method=`GET`,e.body=void 0,e.headers.delete(`content-length`));let s=u(E);return s&&(e.referrerPolicy=s),finalize(),fetch(new d(t,e))}default:throw TypeError(`Redirect option '${h.redirect}' is not a valid value of RequestRedirect`)}}let k={url:h.url,status:D,statusText:O,headers:E,size:h.size,counter:h.counter,highWaterMark:h.highWaterMark},A=E.get(`Content-Encoding`);if(!h.compress||h.method===`HEAD`||A===null||D===204||D===304)return finalize(),new l(C,k);if(typeof DecompressionStream<`u`){let t=null;if(A===`gzip`||A===`x-gzip`?t=`gzip`:(A===`deflate`||A===`x-deflate`)&&(t=`deflate`),t){let n=new e([await new l(C,k).arrayBuffer()]).stream().pipeThrough(new DecompressionStream(t));return finalize(),new l(n,k)}}return finalize(),new l(C,k)}export{p as AbortError,e as Blob,n as FetchError,t as File,v as FormData,o as Headers,d as Request,l as Response,m as XMLHttpRequest,h as XMLHttpRequestUpload,fetch as default,c as isRedirect};
|
package/lib/esm/request.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import"./_virtual/_rolldown/runtime.js";import{isAbortSignal as e}from"./utils/is.js";import t,{clone as n,extractContentType as r,getTotalBytes as i}from"./body.js";import a from"./headers.js";import{inputStreamToReadable as o,soupSendAsync as s}from"./utils/soup-helpers.js";import{DEFAULT_REFERRER_POLICY as c,determineRequestsReferrer as l,validateReferrerPolicy as u}from"./utils/referrer.js";import{URL as d}from"@gjsify/url";import f from"@girs/soup-3.0";import p from"@girs/glib-2.0";import m from"@girs/gio-2.0";const h=Symbol(`Request internals`);let g=null;function envPositiveInt(e,t){let n=p.getenv(e);if(!n)return t;let r=Number.parseInt(n,10);return Number.isFinite(r)&&r>0?r:t}function getSharedSession(){if(g===null){let e=envPositiveInt(`GJSIFY_FETCH_MAX_CONNS_PER_HOST`,16),t=Math.max(envPositiveInt(`GJSIFY_FETCH_MAX_CONNS`,64),e);g=new f.Session({maxConns:t,maxConnsPerHost:e});try{g.remove_feature_by_type(f.ContentDecoder.$gtype)}catch{}}return g}const isRequest=e=>typeof e==`object`&&typeof e.url==`string`;var _=class Request extends t{cache;credentials;destination;get headers(){return this[h].headers}integrity;keepalive;get method(){return this[h].method}mode;get redirect(){return this[h].redirect}get referrer(){if(this[h].referrer===`no-referrer`)return``;if(this[h].referrer===`client`)return`about:client`;if(this[h].referrer)return this[h].referrer.toString()}get referrerPolicy(){return this[h].referrerPolicy}set referrerPolicy(e){this[h].referrerPolicy=u(e)}get signal(){return this[h].signal}get url(){return this[h].parsedURL.toString()}get _uri(){return p.Uri.parse(this.url,p.UriFlags.ENCODED)}get _session(){return this[h].session}get _message(){return this[h].message}get _inputStream(){return this[h].inputStream}get[Symbol.toStringTag](){return`Request`}[h];follow;compress=!1;counter=0;agent=``;highWaterMark=16384;insecureHTTPParser=!1;constructor(t,i){let o=t,s=i||{},c,l={};if(isRequest(t)?(c=new d(o.url),l=o):c=new d(t),c.username!==``||c.password!==``)throw TypeError(`${c} is an url with embedded credentials.`);let u=s.method||l.method||`GET`;if(/^(delete|get|head|options|post|put)$/i.test(u)&&(u=u.toUpperCase()),(i?.body!=null||isRequest(t)&&o.body!==null)&&(u===`GET`||u===`HEAD`))throw TypeError(`Request with GET/HEAD method cannot have body`);let m=i?.body?i.body:isRequest(t)&&o.body!==null?n(t):null;super(m,{size:s.size||0});let g=new a(i?.headers||o.headers||{});if(m!==null&&!g.has(`Content-Type`)){let e=r(m,this);e&&g.set(`Content-Type`,e)}let _=isRequest(t)?o.signal:null;if(i&&`signal`in i&&(_=i.signal),_!=null&&!e(_))throw TypeError(`Expected signal to be an instanceof AbortSignal or EventTarget`);let v=i?.referrer==null?o.referrer:i.referrer;if(v===``)v=`no-referrer`;else if(v){let e=new d(v);v=/^about:(\/\/)?client$/.test(e.toString())?`client`:e}else v=void 0;let y=c.protocol,b=null,x=null;(y===`http:`||y===`https:`)&&(b=getSharedSession(),x=new f.Message({method:u,uri:p.Uri.parse(c.toString(),p.UriFlags.ENCODED)})),this[h]={method:u,redirect:i?.redirect||o.redirect||`follow`,headers:g,parsedURL:c,signal:_,referrer:v,referrerPolicy:``,session:b,message:x},this.follow=s.follow===void 0?o.follow===void 0?20:o.follow:s.follow,this.compress=s.compress===void 0?o.compress===void 0?!0:o.compress:s.compress,this.counter=s.counter||o.counter||0,this.agent=s.agent||o.agent,this.highWaterMark=s.highWaterMark||o.highWaterMark||16384,this.insecureHTTPParser=s.insecureHTTPParser||o.insecureHTTPParser||!1,this.referrerPolicy=i?.referrerPolicy||o.referrerPolicy||``}async _send(e){let{session:t,message:n}=this[h];if(!t||!n)throw Error(`Cannot send request: no Soup session (non-HTTP URL?)`);e.headers._appendToSoupMessage(n);let
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import{isAbortSignal as e}from"./utils/is.js";import t,{clone as n,extractContentType as r,getTotalBytes as i}from"./body.js";import a from"./headers.js";import{inputStreamToReadable as o,soupSendAsync as s}from"./utils/soup-helpers.js";import{DEFAULT_REFERRER_POLICY as c,determineRequestsReferrer as l,validateReferrerPolicy as u}from"./utils/referrer.js";import{URL as d}from"@gjsify/url";import f from"@girs/soup-3.0";import p from"@girs/glib-2.0";import m from"@girs/gio-2.0";const h=Symbol(`Request internals`);let g=null;function envPositiveInt(e,t){let n=p.getenv(e);if(!n)return t;let r=Number.parseInt(n,10);return Number.isFinite(r)&&r>0?r:t}function shouldAcceptAnyCert(e){return e===!1?!0:e===!0?!1:p.getenv(`NODE_TLS_REJECT_UNAUTHORIZED`)===`0`}function getSharedSession(){if(g===null){let e=envPositiveInt(`GJSIFY_FETCH_MAX_CONNS_PER_HOST`,16),t=Math.max(envPositiveInt(`GJSIFY_FETCH_MAX_CONNS`,64),e);g=new f.Session({maxConns:t,maxConnsPerHost:e});try{g.remove_feature_by_type(f.ContentDecoder.$gtype)}catch{}}return g}const isRequest=e=>typeof e==`object`&&typeof e.url==`string`;var _=class Request extends t{cache;credentials;destination;get headers(){return this[h].headers}integrity;keepalive;get method(){return this[h].method}mode;get redirect(){return this[h].redirect}get referrer(){if(this[h].referrer===`no-referrer`)return``;if(this[h].referrer===`client`)return`about:client`;if(this[h].referrer)return this[h].referrer.toString()}get referrerPolicy(){return this[h].referrerPolicy}set referrerPolicy(e){this[h].referrerPolicy=u(e)}get signal(){return this[h].signal}get url(){return this[h].parsedURL.toString()}get _uri(){return p.Uri.parse(this.url,p.UriFlags.ENCODED)}get _session(){return this[h].session}get _message(){return this[h].message}get _inputStream(){return this[h].inputStream}get[Symbol.toStringTag](){return`Request`}[h];follow;compress=!1;counter=0;agent=``;highWaterMark=16384;insecureHTTPParser=!1;rejectUnauthorized;constructor(t,i){let o=t,s=i||{},c,l={};if(isRequest(t)?(c=new d(o.url),l=o):c=new d(t),c.username!==``||c.password!==``)throw TypeError(`${c} is an url with embedded credentials.`);let u=s.method||l.method||`GET`;if(/^(delete|get|head|options|post|put)$/i.test(u)&&(u=u.toUpperCase()),(i?.body!=null||isRequest(t)&&o.body!==null)&&(u===`GET`||u===`HEAD`))throw TypeError(`Request with GET/HEAD method cannot have body`);let m=i?.body?i.body:isRequest(t)&&o.body!==null?n(t):null;super(m,{size:s.size||0});let g=new a(i?.headers||o.headers||{});if(m!==null&&!g.has(`Content-Type`)){let e=r(m,this);e&&g.set(`Content-Type`,e)}let _=isRequest(t)?o.signal:null;if(i&&`signal`in i&&(_=i.signal),_!=null&&!e(_))throw TypeError(`Expected signal to be an instanceof AbortSignal or EventTarget`);let v=i?.referrer==null?o.referrer:i.referrer;if(v===``)v=`no-referrer`;else if(v){let e=new d(v);v=/^about:(\/\/)?client$/.test(e.toString())?`client`:e}else v=void 0;let y=c.protocol,b=null,x=null;(y===`http:`||y===`https:`)&&(b=getSharedSession(),x=new f.Message({method:u,uri:p.Uri.parse(c.toString(),p.UriFlags.ENCODED)})),this[h]={method:u,redirect:i?.redirect||o.redirect||`follow`,headers:g,parsedURL:c,signal:_,referrer:v,referrerPolicy:``,session:b,message:x},this.follow=s.follow===void 0?o.follow===void 0?20:o.follow:s.follow,this.compress=s.compress===void 0?o.compress===void 0?!0:o.compress:s.compress,this.counter=s.counter||o.counter||0,this.agent=s.agent||o.agent,this.highWaterMark=s.highWaterMark||o.highWaterMark||16384,this.insecureHTTPParser=s.insecureHTTPParser||o.insecureHTTPParser||!1,this.rejectUnauthorized=s.rejectUnauthorized??o.rejectUnauthorized,this.referrerPolicy=i?.referrerPolicy||o.referrerPolicy||``}async _send(e){let{session:t,message:n,signal:r,parsedURL:i}=this[h];if(!t||!n)throw Error(`Cannot send request: no Soup session (non-HTTP URL?)`);e.headers._appendToSoupMessage(n);let a=this._rawBodyBuffer;if(a!==null&&a.byteLength>0){let t=e.headers.get(`content-type`)||null;n.set_request_body_from_bytes(t,new p.Bytes(a))}i.protocol===`https:`&&shouldAcceptAnyCert(this.rejectUnauthorized)&&n.connect(`accept-certificate`,(e,t,n)=>!0);let c=new m.Cancellable;return r&&(r.aborted?c.cancel():r.addEventListener(`abort`,()=>c.cancel(),{once:!0})),this[h].inputStream=await s(t,n,p.PRIORITY_DEFAULT,c),this[h].readable=o(this[h].inputStream),{inputStream:this[h].inputStream,readable:this[h].readable,cancellable:c}}clone(){return new Request(this)}async arrayBuffer(){return super.arrayBuffer()}async blob(){return super.blob()}async formData(){return super.formData()}async json(){return super.json()}async text(){return super.text()}};Object.defineProperties(_.prototype,{method:{enumerable:!0},url:{enumerable:!0},headers:{enumerable:!0},redirect:{enumerable:!0},clone:{enumerable:!0},signal:{enumerable:!0},referrer:{enumerable:!0},referrerPolicy:{enumerable:!0}});const getSoupRequestOptions=e=>{let{parsedURL:t}=e[h],n=new a(e[h].headers);n.has(`Accept`)||n.set(`Accept`,`*/*`);let r=null;if(e.body===null&&/^(post|put)$/i.test(e.method)&&(r=`0`),e.body!==null){let t=i(e);typeof t==`number`&&!Number.isNaN(t)&&(r=String(t))}r&&n.set(`Content-Length`,r),e.referrerPolicy===``&&(e.referrerPolicy=c),e.referrer&&e.referrer!==`no-referrer`?e[h].referrer=l(e):e[h].referrer=`no-referrer`,e[h].referrer instanceof d&&n.set(`Referer`,e.referrer),n.has(`User-Agent`)||n.set(`User-Agent`,`gjsify-fetch`),e.compress&&!n.has(`Accept-Encoding`)&&n.set(`Accept-Encoding`,`gzip, deflate`);let{agent:o}=e;return typeof o==`function`&&(o=o(t)),!n.has(`Connection`)&&!o&&n.set(`Connection`,`close`),{parsedURL:t,options:{headers:n}}};export{_ as Request,_ as default,getSoupRequestOptions};
|
package/lib/index.js
CHANGED
|
@@ -112,6 +112,11 @@ export default async function fetch(url, init = {}) {
|
|
|
112
112
|
cancellable = sendRes.cancellable;
|
|
113
113
|
}
|
|
114
114
|
catch (error) {
|
|
115
|
+
// A send aborted during connect/handshake/TTFB surfaces as a cancelled send_async — report it
|
|
116
|
+
// as an AbortError (Node parity), not a generic system FetchError.
|
|
117
|
+
if (signal && signal.aborted) {
|
|
118
|
+
throw new AbortError('The operation was aborted.');
|
|
119
|
+
}
|
|
115
120
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
116
121
|
throw new FetchError(`request to ${request.url} failed, reason: ${err.message}`, 'system', err);
|
|
117
122
|
}
|
package/lib/request.d.ts
CHANGED
|
@@ -68,6 +68,8 @@ export declare class Request extends Body {
|
|
|
68
68
|
agent: string | ((url: URL) => string);
|
|
69
69
|
highWaterMark: number;
|
|
70
70
|
insecureHTTPParser: boolean;
|
|
71
|
+
/** Per-request TLS opt-out (undefined ⇒ verify, honoring NODE_TLS_REJECT_UNAUTHORIZED). */
|
|
72
|
+
rejectUnauthorized?: boolean;
|
|
71
73
|
constructor(input: RequestInfo | URL | Request, init?: RequestInit);
|
|
72
74
|
/**
|
|
73
75
|
* Send the request using Soup.
|
package/lib/request.js
CHANGED
|
@@ -73,6 +73,18 @@ function envPositiveInt(name, fallback) {
|
|
|
73
73
|
const n = Number.parseInt(raw, 10);
|
|
74
74
|
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether to accept ANY peer certificate for a request. Node parity: an explicit
|
|
78
|
+
* `rejectUnauthorized` wins (true keeps verification even under the env override); otherwise the
|
|
79
|
+
* process-global `NODE_TLS_REJECT_UNAUTHORIZED=0` disables verification (as Node's fetch does).
|
|
80
|
+
*/
|
|
81
|
+
function shouldAcceptAnyCert(rejectUnauthorized) {
|
|
82
|
+
if (rejectUnauthorized === false)
|
|
83
|
+
return true;
|
|
84
|
+
if (rejectUnauthorized === true)
|
|
85
|
+
return false;
|
|
86
|
+
return GLib.getenv('NODE_TLS_REJECT_UNAUTHORIZED') === '0';
|
|
87
|
+
}
|
|
76
88
|
function getSharedSession() {
|
|
77
89
|
if (sharedSession === null) {
|
|
78
90
|
const maxPerHost = envPositiveInt('GJSIFY_FETCH_MAX_CONNS_PER_HOST', DEFAULT_MAX_CONNS_PER_HOST);
|
|
@@ -193,6 +205,8 @@ export class Request extends Body {
|
|
|
193
205
|
agent = '';
|
|
194
206
|
highWaterMark = 16384;
|
|
195
207
|
insecureHTTPParser = false;
|
|
208
|
+
/** Per-request TLS opt-out (undefined ⇒ verify, honoring NODE_TLS_REJECT_UNAUTHORIZED). */
|
|
209
|
+
rejectUnauthorized;
|
|
196
210
|
constructor(input, init) {
|
|
197
211
|
const inputRL = input;
|
|
198
212
|
const initRL = (init || {});
|
|
@@ -303,6 +317,7 @@ export class Request extends Body {
|
|
|
303
317
|
this.agent = initRL.agent || inputRL.agent;
|
|
304
318
|
this.highWaterMark = initRL.highWaterMark || inputRL.highWaterMark || 16384;
|
|
305
319
|
this.insecureHTTPParser = initRL.insecureHTTPParser || inputRL.insecureHTTPParser || false;
|
|
320
|
+
this.rejectUnauthorized = initRL.rejectUnauthorized ?? inputRL.rejectUnauthorized;
|
|
306
321
|
// §5.4, Request constructor steps, step 16.
|
|
307
322
|
// Default is empty string per https://fetch.spec.whatwg.org/#concept-request-referrer-policy
|
|
308
323
|
this.referrerPolicy = init?.referrerPolicy || inputRL.referrerPolicy || '';
|
|
@@ -311,7 +326,7 @@ export class Request extends Body {
|
|
|
311
326
|
* Send the request using Soup.
|
|
312
327
|
*/
|
|
313
328
|
async _send(options) {
|
|
314
|
-
const { session, message } = this[INTERNALS];
|
|
329
|
+
const { session, message, signal, parsedURL } = this[INTERNALS];
|
|
315
330
|
if (!session || !message) {
|
|
316
331
|
throw new Error('Cannot send request: no Soup session (non-HTTP URL?)');
|
|
317
332
|
}
|
|
@@ -328,7 +343,29 @@ export class Request extends Body {
|
|
|
328
343
|
const contentType = options.headers.get('content-type') || null;
|
|
329
344
|
message.set_request_body_from_bytes(contentType, new GLib.Bytes(rawBuf));
|
|
330
345
|
}
|
|
346
|
+
// Per-connection TLS policy: when this request opts out of verification
|
|
347
|
+
// (`rejectUnauthorized: false`, or the Node-global NODE_TLS_REJECT_UNAUTHORIZED=0), accept the
|
|
348
|
+
// peer cert for THIS message only — never the shared session, which would silently disable
|
|
349
|
+
// verification for every other fetch. Mirrors the `accept-certificate` idiom in
|
|
350
|
+
// @gjsify/{tls,http2}. `accept-certificate` lives on Soup.Message (not the session) in
|
|
351
|
+
// libsoup 3 and isn't in the typed SignalSignatures map, so widen to the string overload.
|
|
352
|
+
if (parsedURL.protocol === 'https:' && shouldAcceptAnyCert(this.rejectUnauthorized)) {
|
|
353
|
+
const signalName = 'accept-certificate';
|
|
354
|
+
message.connect(signalName, (_msg, _cert, _errors) => true);
|
|
355
|
+
}
|
|
331
356
|
const cancellable = new Gio.Cancellable();
|
|
357
|
+
// Close the connect/TTFB abort gap: wire abort → cancel BEFORE send_async. index.ts only wires
|
|
358
|
+
// it AFTER _send resolves (i.e. after response headers arrive), so an abort fired during
|
|
359
|
+
// connect + request-send + time-to-first-byte was previously dropped — the hung-server timeout
|
|
360
|
+
// case. A cancelled send_async rejects; index.ts maps that to an AbortError when signal.aborted.
|
|
361
|
+
if (signal) {
|
|
362
|
+
if (signal.aborted) {
|
|
363
|
+
cancellable.cancel();
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
signal.addEventListener('abort', () => cancellable.cancel(), { once: true });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
332
369
|
this[INTERNALS].inputStream = await soupSendAsync(session, message, GLib.PRIORITY_DEFAULT, cancellable);
|
|
333
370
|
this[INTERNALS].readable = inputStreamToReadable(this[INTERNALS].inputStream);
|
|
334
371
|
return {
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// TLS opt-out + abort behavior for @gjsify/fetch on GJS — original regression tests.
|
|
2
|
+
//
|
|
3
|
+
// Covers two Node-parity gaps closed in request.ts / index.ts:
|
|
4
|
+
// 1. Per-connection TLS: a request may opt out of certificate verification with
|
|
5
|
+
// `rejectUnauthorized: false` (mirroring undici's `connect.rejectUnauthorized`) — applied to
|
|
6
|
+
// THAT Soup.Message only via its `accept-certificate` signal, never the shared session. The
|
|
7
|
+
// process-global `NODE_TLS_REJECT_UNAUTHORIZED=0` is also honored (as Node's fetch does).
|
|
8
|
+
// 2. Abort: an aborted signal rejects the fetch with an AbortError (name === 'AbortError'), and
|
|
9
|
+
// the abort→cancel wiring is attached BEFORE send_async so an abort during connect/TTFB is not
|
|
10
|
+
// lost (see request.ts `_send`).
|
|
11
|
+
//
|
|
12
|
+
// GJS-only (`.gjs.spec.ts`): the body runs under `on('Gjs', …)`, a no-op on Node. Soup/GLib/Gio are
|
|
13
|
+
// read from the GJS bootstrap `imports` (same idiom as soup-session.gjs.spec.ts) so the Node bundle
|
|
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.
|
|
17
|
+
import { describe, it, expect, on } from '@gjsify/unit';
|
|
18
|
+
import { AbortController, AbortSignal } from '@gjsify/abort-controller';
|
|
19
|
+
// A REAL self-signed cert+key (RSA-2048, CN=localhost, SAN DNS:localhost + IP:127.0.0.1, 100y) so a
|
|
20
|
+
// live TLS handshake against the local Soup.Server actually completes. Embedded inline to stay
|
|
21
|
+
// hermetic (no fixtures) — same approach as packages/node/tls TEST_CERT_AND_KEY. Regenerate with:
|
|
22
|
+
// openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 36500 \
|
|
23
|
+
// -subj /CN=localhost -addext "subjectAltName = DNS:localhost,IP:127.0.0.1"
|
|
24
|
+
const SELF_SIGNED_CERT_AND_KEY = `-----BEGIN CERTIFICATE-----
|
|
25
|
+
MIIDJzCCAg+gAwIBAgIUKYxN2Fykn0Sr0YIq7h11xSofpYQwDQYJKoZIhvcNAQEL
|
|
26
|
+
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDcxMTA4MzcwN1oYDzIxMjYw
|
|
27
|
+
NjE3MDgzNzA3WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
|
|
28
|
+
AQUAA4IBDwAwggEKAoIBAQC9S/lXTWdjIpw7dI2v2095KssX99/Kh6xznHZ9Ed3D
|
|
29
|
+
gGGivMXSkPJfFeEj/LecSTJn0YVxGtu/tdon3Gnswk3JfFBuzm0MG5ks4puRkKsu
|
|
30
|
+
JV8+TtpcsBHVglTVnTqiw44yfk+VZdLomZHdcd61sTG7r97+TtVHbjZqQ0rKjhwB
|
|
31
|
+
fv3TysLrvE07rrB1UbagISroYg+G5EysQH0Yu9JbRsvefOKaK9vwq/jbrdSWFrez
|
|
32
|
+
R6xePGZsInr38Zt57O32ok0HMsT+DOeB+2GE0xd4+gEgr3XXHSP2PrWOtDtq74d0
|
|
33
|
+
25H9kFpp7C4kypY/lt6/i6D7CKu685zK68EKyb3EvyubAgMBAAGjbzBtMB0GA1Ud
|
|
34
|
+
DgQWBBTkl+nvQOXFs4mOZRQFIXy9MCCjJjAfBgNVHSMEGDAWgBTkl+nvQOXFs4mO
|
|
35
|
+
ZRQFIXy9MCCjJjAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z
|
|
36
|
+
dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAfkpo3YBTfdLAGBcrkDo0WSJYpPAK
|
|
37
|
+
4rIZ3zk8aWeMzockt9NZ3/1+1OwtFDmL2bHHxHJF03DuPbgQR4uq3EBNPWGP72dy
|
|
38
|
+
+WsTGe6NJakt3QVYq5+73mtxy7fu/e4IF0vPQ9O/euDZoPQiplT7u/AcBcVZvlRv
|
|
39
|
+
t/TGdolGlGT7lzx6WYXrAmpCyQnQu/CU/OM9DBFVy22i+QbtJpd6HJdr68Nry56V
|
|
40
|
+
exU4w6PAIpKdAY4fayvooSDBCOetDuYJ8sIeiFM4xNwK55h/dedvEZWbsHr5OwJ6
|
|
41
|
+
dW8IEugXvvXvGrs3QJjSOd1B9HFsmZLyzZ5Ym3s6jl96DaTEvqBM5kKMVw==
|
|
42
|
+
-----END CERTIFICATE-----
|
|
43
|
+
-----BEGIN PRIVATE KEY-----
|
|
44
|
+
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC9S/lXTWdjIpw7
|
|
45
|
+
dI2v2095KssX99/Kh6xznHZ9Ed3DgGGivMXSkPJfFeEj/LecSTJn0YVxGtu/tdon
|
|
46
|
+
3Gnswk3JfFBuzm0MG5ks4puRkKsuJV8+TtpcsBHVglTVnTqiw44yfk+VZdLomZHd
|
|
47
|
+
cd61sTG7r97+TtVHbjZqQ0rKjhwBfv3TysLrvE07rrB1UbagISroYg+G5EysQH0Y
|
|
48
|
+
u9JbRsvefOKaK9vwq/jbrdSWFrezR6xePGZsInr38Zt57O32ok0HMsT+DOeB+2GE
|
|
49
|
+
0xd4+gEgr3XXHSP2PrWOtDtq74d025H9kFpp7C4kypY/lt6/i6D7CKu685zK68EK
|
|
50
|
+
yb3EvyubAgMBAAECggEABQaKqFFuUYIiKaUafByGSqXIlLmVsdwSMQfqlFX9l7uj
|
|
51
|
+
m/Hhs68YGSG9x0lPOcgBd+aMcMZtcfwUL1u7P9ZF0Fm8d2YLSaDqTB28njywrzZU
|
|
52
|
+
nI2LFGJ2v6/QQvFWo8yXAVulLTAuLy2R+bey0hodSt55l0/wL3FS4pGgubjyTgr+
|
|
53
|
+
ZY4DWjxwdPqZSC29nBN6HtOU0LMt+0UZS4QoSkb4LP/OSG7ZfZk2ssuVJioZnNwS
|
|
54
|
+
Gh6XB7YjzDkMcYtZ9Os8rvFdeECqM53N3DT8RHImbQZpkamodhJR5/Y7MNFAfg0l
|
|
55
|
+
yISz8u47iS2ZJV/3ePbxOotRtAPLbyErfo1PVkOsgQKBgQDz53RUbKTvEzXTVcTW
|
|
56
|
+
MhDc4pNtT7FWNmAhYCaVboJk6jisWOagzUHSZyJd+KFOPCwW0TZAd3DXbAjBCVg7
|
|
57
|
+
/Yuxu0K3tvYsDRAyaKrDrF4I03aS3d7GlbaNBxNmWZXwd6UloLmXQvmWooX7FlfM
|
|
58
|
+
WXV2BgfEc/RaQV4e+CcZgd96GwKBgQDGrz0HsgaiqBknWtDjVJEEnU3DMEEWPF6u
|
|
59
|
+
NQ+YZ3gOJE7yIIs/aJXK1TJE+i+CMI9y+O50eyHIqE6xbF/aM0gTgAzzWPVwan4z
|
|
60
|
+
P+8MZcHrRrLUWCi/hULQXjzKAh33zUc8+npEewIIaIe2SQp/j4NTYKjYqXS6Vkvh
|
|
61
|
+
cR6oQNIsgQKBgQCExsJ4VzCuiZmqs61VIao2ZQ322wRiN3W7lZlLjf5kK3GwuTDL
|
|
62
|
+
5xFnFggKehht+6nQvhG3pI4EZ+aYF5s2BT/wAE5ArtRyhKYFmx8jKImEaoHJUAk/
|
|
63
|
+
uC7JtUllVW2fm7KGumluI+K1k0I2vyIKt8SO/Jdr5efm3mD7oQHADAh5cwKBgHUq
|
|
64
|
+
slrvaWpSp0LGE5l8LWkDOvB4bGmogE1LjT7bBbmRP90ZxARIsM1EnLiAsbOc33t1
|
|
65
|
+
wWl/k5S5bG7E8mHDDCYzxW9cbqE0q+edNGOjPpB4yMzgkGchNx2Z9U9LoFCaBgC3
|
|
66
|
+
1ZK8154NxrqAqGhBQbjRr8DVbbjVWzo/c0zVrRwBAoGBAJ8USM/06E60u0Tq8zU9
|
|
67
|
+
q1z0p3hu++Gude5e9IHp3tnS2/MLIbJDyfvkVkx5gLJXKYdEykP10CLOqu8DC1c5
|
|
68
|
+
9FvsYKlRm0GzDlTtX12jXGWHaQXy9Di69Sd3J6StLf04IEl3W4e9KTg7zDA9cg94
|
|
69
|
+
StMiAiohwWMqIzCc88GovQMF
|
|
70
|
+
-----END PRIVATE KEY-----
|
|
71
|
+
`;
|
|
72
|
+
export default async () => {
|
|
73
|
+
await on('Gjs', async () => {
|
|
74
|
+
// Read GJS runtime objects lazily (no static gi:// import → Node bundle stays clean).
|
|
75
|
+
const gjs = globalThis.imports;
|
|
76
|
+
const Soup = gjs.gi.Soup;
|
|
77
|
+
const GLib = gjs.gi.GLib;
|
|
78
|
+
const Gio = gjs.gi.Gio;
|
|
79
|
+
// Request + fetch are installed as globals by `@gjsify/fetch/register` (via test.mts). Type
|
|
80
|
+
// them with FetchInit so the GJS-only options type-check under the fetch package's DOM lib.
|
|
81
|
+
const RequestCtor = globalThis
|
|
82
|
+
.Request;
|
|
83
|
+
const fetchFn = globalThis
|
|
84
|
+
.fetch;
|
|
85
|
+
/** Start a fresh self-signed HTTPS Soup.Server on loopback; returns it plus its base URL. */
|
|
86
|
+
const startHttpsServer = () => {
|
|
87
|
+
const cert = Gio.TlsCertificate.new_from_pem(SELF_SIGNED_CERT_AND_KEY, -1);
|
|
88
|
+
const server = new Soup.Server({});
|
|
89
|
+
server.set_tls_certificate(cert);
|
|
90
|
+
server.add_handler(null, (_s, msg) => {
|
|
91
|
+
msg.set_status(200, null);
|
|
92
|
+
msg.set_response('text/plain', Soup.MemoryUse.COPY, new TextEncoder().encode('ok'));
|
|
93
|
+
});
|
|
94
|
+
server.listen_local(0, (Soup.ServerListenOptions.HTTPS | Soup.ServerListenOptions.IPV4_ONLY));
|
|
95
|
+
return { server, base: server.get_uris()[0].to_string() };
|
|
96
|
+
};
|
|
97
|
+
await describe('@gjsify/fetch — per-connection TLS opt-out plumbing', async () => {
|
|
98
|
+
await it('carries rejectUnauthorized:false from init onto the request', () => {
|
|
99
|
+
const req = new RequestCtor('https://example.com/', { rejectUnauthorized: false });
|
|
100
|
+
expect(req.rejectUnauthorized).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
await it('defaults rejectUnauthorized to undefined (verification stays on)', () => {
|
|
103
|
+
const req = new RequestCtor('https://example.com/');
|
|
104
|
+
expect(req.rejectUnauthorized).toBe(undefined);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
await describe('@gjsify/fetch — abort surfaces as AbortError', async () => {
|
|
108
|
+
await it('rejects with an AbortError when the signal is already aborted', async () => {
|
|
109
|
+
const ctrl = new AbortController();
|
|
110
|
+
ctrl.abort();
|
|
111
|
+
let name = '';
|
|
112
|
+
try {
|
|
113
|
+
// Already-aborted → rejects before any socket work, so no server is needed.
|
|
114
|
+
await fetchFn('http://127.0.0.1:1/', { signal: ctrl.signal });
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
name = e.name;
|
|
118
|
+
}
|
|
119
|
+
expect(name).toBe('AbortError');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
await describe('@gjsify/fetch — self-signed TLS (per-connection opt-out, no shared-session leak)', async () => {
|
|
123
|
+
await it('rejects a self-signed host by default (verification on)', async () => {
|
|
124
|
+
const { server, base } = startHttpsServer();
|
|
125
|
+
let rejected = false;
|
|
126
|
+
try {
|
|
127
|
+
await fetchFn(base);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
rejected = true;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
server.disconnect();
|
|
134
|
+
}
|
|
135
|
+
expect(rejected).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
await it('resolves when the request opts out with rejectUnauthorized:false', async () => {
|
|
138
|
+
const { server, base } = startHttpsServer();
|
|
139
|
+
let status = 0;
|
|
140
|
+
let body = '';
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetchFn(base, { rejectUnauthorized: false });
|
|
143
|
+
status = res.status;
|
|
144
|
+
body = await res.text();
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
server.disconnect();
|
|
148
|
+
}
|
|
149
|
+
expect(status).toBe(200);
|
|
150
|
+
expect(body).toBe('ok');
|
|
151
|
+
});
|
|
152
|
+
await it('honors NODE_TLS_REJECT_UNAUTHORIZED=0 for the default call', async () => {
|
|
153
|
+
const { server, base } = startHttpsServer();
|
|
154
|
+
const prev = GLib.getenv('NODE_TLS_REJECT_UNAUTHORIZED');
|
|
155
|
+
GLib.setenv('NODE_TLS_REJECT_UNAUTHORIZED', '0', true);
|
|
156
|
+
let status = 0;
|
|
157
|
+
try {
|
|
158
|
+
const res = await fetchFn(base); // no per-request opt-out → env is what opens it
|
|
159
|
+
status = res.status;
|
|
160
|
+
await res.text();
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
if (prev === null)
|
|
164
|
+
GLib.unsetenv('NODE_TLS_REJECT_UNAUTHORIZED');
|
|
165
|
+
else
|
|
166
|
+
GLib.setenv('NODE_TLS_REJECT_UNAUTHORIZED', prev, true);
|
|
167
|
+
server.disconnect();
|
|
168
|
+
}
|
|
169
|
+
expect(status).toBe(200);
|
|
170
|
+
});
|
|
171
|
+
await it('lets an explicit rejectUnauthorized:true win over NODE_TLS_REJECT_UNAUTHORIZED=0', async () => {
|
|
172
|
+
const { server, base } = startHttpsServer();
|
|
173
|
+
const prev = GLib.getenv('NODE_TLS_REJECT_UNAUTHORIZED');
|
|
174
|
+
GLib.setenv('NODE_TLS_REJECT_UNAUTHORIZED', '0', true);
|
|
175
|
+
let rejected = false;
|
|
176
|
+
try {
|
|
177
|
+
await fetchFn(base, { rejectUnauthorized: true });
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
rejected = true;
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
if (prev === null)
|
|
184
|
+
GLib.unsetenv('NODE_TLS_REJECT_UNAUTHORIZED');
|
|
185
|
+
else
|
|
186
|
+
GLib.setenv('NODE_TLS_REJECT_UNAUTHORIZED', prev, true);
|
|
187
|
+
server.disconnect();
|
|
188
|
+
}
|
|
189
|
+
expect(rejected).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
await it('scopes the accepted cert to the message: a normal fetch elsewhere still verifies', async () => {
|
|
192
|
+
// Two independent self-signed servers. Accepting server A's cert (per-message) must NOT
|
|
193
|
+
// teach the shared Soup.Session to trust server B — B is a fresh host:port with no
|
|
194
|
+
// pooled connection, so it must handshake + verify from scratch and reject.
|
|
195
|
+
const a = startHttpsServer();
|
|
196
|
+
const b = startHttpsServer();
|
|
197
|
+
let insecureStatus = 0;
|
|
198
|
+
let secureRejected = false;
|
|
199
|
+
try {
|
|
200
|
+
const res = await fetchFn(a.base, { rejectUnauthorized: false });
|
|
201
|
+
insecureStatus = res.status;
|
|
202
|
+
await res.text();
|
|
203
|
+
try {
|
|
204
|
+
await fetchFn(b.base); // default on the SAME shared session must still verify
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
secureRejected = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
a.server.disconnect();
|
|
212
|
+
b.server.disconnect();
|
|
213
|
+
}
|
|
214
|
+
expect(insecureStatus).toBe(200);
|
|
215
|
+
expect(secureRejected).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
await describe('@gjsify/fetch — AbortSignal.timeout against a hung server rejects promptly', async () => {
|
|
219
|
+
await it('rejects with AbortError while waiting for response headers (connect-phase fix)', async () => {
|
|
220
|
+
const server = new Soup.Server({});
|
|
221
|
+
server.add_handler(null, (_s, msg) => {
|
|
222
|
+
// Never respond: pause the message so the client blocks on time-to-first-byte. On
|
|
223
|
+
// `main` the abort wired only AFTER _send resolves is dropped here and the fetch
|
|
224
|
+
// hangs; the fix wires it before send_async, so AbortSignal.timeout cancels the
|
|
225
|
+
// in-flight send and index.ts maps the cancellation to an AbortError.
|
|
226
|
+
server.pause_message(msg);
|
|
227
|
+
});
|
|
228
|
+
server.listen_local(0, Soup.ServerListenOptions.IPV4_ONLY);
|
|
229
|
+
const base = server.get_uris()[0].to_string();
|
|
230
|
+
// Bounded so a regression fails the test instead of hanging CI: if the fetch has not
|
|
231
|
+
// settled within 3s the watchdog rejects and the AbortError assertion below fails.
|
|
232
|
+
let watchdogId = 0;
|
|
233
|
+
const watchdog = new Promise((_resolve, reject) => {
|
|
234
|
+
watchdogId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 3000, () => {
|
|
235
|
+
watchdogId = 0;
|
|
236
|
+
reject(new Error('fetch did not settle within 3s — connect-phase abort regressed'));
|
|
237
|
+
return GLib.SOURCE_REMOVE;
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
const startedUs = GLib.get_monotonic_time();
|
|
241
|
+
let name = '';
|
|
242
|
+
let elapsedMs = 0;
|
|
243
|
+
try {
|
|
244
|
+
await Promise.race([fetchFn(base, { signal: AbortSignal.timeout(50) }), watchdog]);
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
name = e.name;
|
|
248
|
+
elapsedMs = (GLib.get_monotonic_time() - startedUs) / 1000;
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
if (watchdogId) {
|
|
252
|
+
GLib.source_remove(watchdogId);
|
|
253
|
+
watchdogId = 0;
|
|
254
|
+
}
|
|
255
|
+
server.disconnect();
|
|
256
|
+
}
|
|
257
|
+
expect(name).toBe('AbortError');
|
|
258
|
+
// The 50ms timeout must fire during connect/TTFB — well under the 3s watchdog.
|
|
259
|
+
expect(elapsedMs < 2000).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/fetch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"build:test:gjs": "gjsify build src/test.mts --app gjs --outfile test.gjs.mjs",
|
|
42
42
|
"build:test:browser": "gjsify build src/test.browser.mts --app browser --outfile dist/test.browser.mjs",
|
|
43
43
|
"build:test:node": "gjsify build src/test.mts --app node --outfile test.node.mjs",
|
|
44
|
-
"test": "gjsify run build:gjsify && gjsify run build:test:node && gjsify run test:
|
|
44
|
+
"test": "gjsify run build:gjsify && gjsify run build:test && gjsify run test:node && gjsify run test:gjs",
|
|
45
45
|
"test:gjs": "gjsify run test.gjs.mjs",
|
|
46
46
|
"test:node": "node test.node.mjs"
|
|
47
47
|
},
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"fetch"
|
|
52
52
|
],
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@gjsify/cli": "^0.
|
|
55
|
-
"@gjsify/unit": "^0.
|
|
54
|
+
"@gjsify/cli": "^0.18.0",
|
|
55
|
+
"@gjsify/unit": "^0.18.0",
|
|
56
56
|
"@types/node": "^25.9.2",
|
|
57
57
|
"typescript": "^6.0.3"
|
|
58
58
|
},
|
|
@@ -61,10 +61,10 @@
|
|
|
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/formdata": "^0.
|
|
65
|
-
"@gjsify/http": "^0.
|
|
66
|
-
"@gjsify/url": "^0.
|
|
67
|
-
"@gjsify/utils": "^0.
|
|
64
|
+
"@gjsify/formdata": "^0.18.0",
|
|
65
|
+
"@gjsify/http": "^0.18.0",
|
|
66
|
+
"@gjsify/url": "^0.18.0",
|
|
67
|
+
"@gjsify/utils": "^0.18.0"
|
|
68
68
|
},
|
|
69
69
|
"gjsify": {
|
|
70
70
|
"runtimes": {
|