@gjsify/fetch 0.30.0 → 0.32.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/headers.js +1 -1
- package/lib/esm/index.js +1 -1
- package/lib/esm/request.js +1 -1
- package/lib/esm/utils/soup-lazy.js +1 -0
- package/lib/headers.d.ts +7 -3
- package/lib/headers.js +8 -5
- package/lib/index.js +5 -1
- package/lib/request.d.ts +1 -1
- package/lib/request.js +41 -35
- package/lib/soup-session.gjs.spec.js +31 -7
- package/lib/utils/soup-lazy.d.ts +4 -0
- package/lib/utils/soup-lazy.js +6 -0
- package/package.json +8 -8
package/lib/esm/headers.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import"./_virtual/_rolldown/runtime.js";import
|
|
1
|
+
import"./_virtual/_rolldown/runtime.js";import{validateHeaderName as e,validateHeaderValue as t}from"@gjsify/http/validators";const n=Symbol(`Headers.headers`);function isBoxedPrimitive(e){return e instanceof String||e instanceof Number||e instanceof Boolean||typeof Symbol<`u`&&e instanceof Symbol||typeof BigInt<`u`&&e instanceof BigInt}var r=class Headers{[n];constructor(r){if(this[n]=new Map,r!=null){if(r instanceof Headers){for(let[e,t]of r[n])this[n].set(e,[...t]);return}if(typeof r==`object`&&!isBoxedPrimitive(r)){let n=r[Symbol.iterator];if(n==null)for(let[n,i]of Object.entries(r))e(n),t(n,String(i)),this.append(n,String(i));else{if(typeof n!=`function`)throw TypeError(`Header pairs must be iterable`);for(let n of r){if(typeof n!=`object`||isBoxedPrimitive(n))throw TypeError(`Each header pair must be an iterable object`);let r=[...n];if(r.length!==2)throw TypeError(`Each header pair must be a name/value tuple`);e(r[0]),t(r[0],String(r[1])),this.append(r[0],String(r[1]))}}}else throw TypeError(`Failed to construct 'Headers': The provided value is not of type '(sequence<sequence<ByteString>> or record<ByteString, ByteString>)'`)}}append(r,i){e(r),t(r,i);let a=String(r).toLowerCase(),o=String(i),s=this[n].get(a);s?s.push(o):this[n].set(a,[o])}set(r,i){e(r),t(r,i);let a=String(r).toLowerCase();this[n].set(a,[String(i)])}delete(e){this[n].delete(String(e).toLowerCase())}has(e){return this[n].has(String(e).toLowerCase())}get(e){let t=this[n].get(String(e).toLowerCase());if(!t||t.length===0)return null;let r=t.join(`, `);return/^content-encoding$/i.test(e)&&(r=r.toLowerCase()),r}getAll(e){return this[n].get(String(e).toLowerCase())??[]}getSetCookie(){return this[n].get(`set-cookie`)??[]}forEach(e,t){for(let n of this.keys())Reflect.apply(e,t,[this.get(n),n,this])}*keys(){let e=[...this[n].keys()].sort(),t=new Set;for(let n of e)t.has(n)||(t.add(n),yield n)}*values(){for(let e of this.keys())yield this.get(e)}*entries(){for(let e of this.keys())yield[e,this.get(e)]}[Symbol.iterator](){return this.entries()}get[Symbol.toStringTag](){return`Headers`}toString(){return Object.prototype.toString.call(this)}raw(){let e={};for(let t of this.keys())e[t]=this.getAll(t);return e}_appendToSoupMessage(e,t,n=e.MessageHeadersType.REQUEST){let r=t?t.get_request_headers():new e.MessageHeaders(n);for(let[e,t]of this.entries())r.append(e,t);return r}static _newFromSoupMessage(e,t,n=e.MessageHeadersType.RESPONSE){let r=new Headers,i;return i=n===e.MessageHeadersType.RESPONSE?t.get_response_headers():t.get_request_headers(),i.foreach((e,t)=>{r.append(e,t)}),r}[Symbol.for(`nodejs.util.inspect.custom`)](){let e={};for(let t of this.keys()){let n=this.getAll(t);t===`host`?e[t]=n[0]:e[t]=n.length>1?n:n[0]}return e}};Object.defineProperties(r.prototype,[`get`,`entries`,`forEach`,`values`].reduce((e,t)=>(e[t]={enumerable:!0},e),{}));export{r as default};
|
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{
|
|
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};
|
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
|
|
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{loadSoup as c}from"./utils/soup-lazy.js";import{DEFAULT_REFERRER_POLICY as l,determineRequestsReferrer as u,validateReferrerPolicy as d}from"./utils/referrer.js";import{URL as f}from"@gjsify/url";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(e){if(g===null){let t=envPositiveInt(`GJSIFY_FETCH_MAX_CONNS_PER_HOST`,16),n=Math.max(envPositiveInt(`GJSIFY_FETCH_MAX_CONNS`,64),t);g=new e.Session({maxConns:n,maxConnsPerHost:t}),g.remove_feature_by_type(e.ContentDecoder.$gtype)}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=d(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 f(o.url),l=o):c=new f(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 d=i?.body?i.body:isRequest(t)&&o.body!==null?n(t):null;super(d,{size:s.size||0});let p=new a(i?.headers||o.headers||{});if(d!==null&&!p.has(`Content-Type`)){let e=r(d,this);e&&p.set(`Content-Type`,e)}let m=isRequest(t)?o.signal:null;if(i&&`signal`in i&&(m=i.signal),m!=null&&!e(m))throw TypeError(`Expected signal to be an instanceof AbortSignal or EventTarget`);let g=i?.referrer==null?o.referrer:i.referrer;if(g===``)g=`no-referrer`;else if(g){let e=new f(g);g=/^about:(\/\/)?client$/.test(e.toString())?`client`:e}else g=void 0;this[h]={method:u,redirect:i?.redirect||o.redirect||`follow`,headers:p,parsedURL:c,signal:m,referrer:g,referrerPolicy:``,session:null,message:null},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{signal:t,parsedURL:n}=this[h],r=n.protocol;if(r!==`http:`&&r!==`https:`)throw Error(`Cannot send request: no Soup session (non-HTTP URL?)`);let i=await c(),a=getSharedSession(i),l=new i.Message({method:this[h].method,uri:p.Uri.parse(n.toString(),p.UriFlags.ENCODED)});this[h].session=a,this[h].message=l,e.headers._appendToSoupMessage(i,l);let u=this._rawBodyBuffer;if(u!==null&&u.byteLength>0){let t=e.headers.get(`content-type`)||null;l.set_request_body_from_bytes(t,new p.Bytes(u))}n.protocol===`https:`&&shouldAcceptAnyCert(this.rejectUnauthorized)&&l.connect(`accept-certificate`,(e,t,n)=>!0);let d=new m.Cancellable;return t&&(t.aborted?d.cancel():t.addEventListener(`abort`,()=>d.cancel(),{once:!0})),this[h].inputStream=await s(a,l,p.PRIORITY_DEFAULT,d),this[h].readable=o(this[h].inputStream),{inputStream:this[h].inputStream,readable:this[h].readable,cancellable:d}}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=l),e.referrer&&e.referrer!==`no-referrer`?e[h].referrer=u(e):e[h].referrer=`no-referrer`,e[h].referrer instanceof f&&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};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import"../_virtual/_rolldown/runtime.js";let e=null;async function loadSoup(){return e??=(await import(`gi://Soup?version=3.0`)).default}export{loadSoup};
|
package/lib/headers.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import Soup from '@girs/soup-3.0';
|
|
1
|
+
import type Soup from '@girs/soup-3.0';
|
|
2
2
|
declare const _headers: unique symbol;
|
|
3
3
|
export default class Headers implements Iterable<[string, string]> {
|
|
4
4
|
[_headers]: Map<string, string[]>;
|
|
@@ -23,11 +23,15 @@ export default class Headers implements Iterable<[string, string]> {
|
|
|
23
23
|
raw(): Record<string, string[]>;
|
|
24
24
|
/**
|
|
25
25
|
* Append all headers to a Soup.Message for sending.
|
|
26
|
+
*
|
|
27
|
+
* `soup` is the lazily-loaded Soup namespace (see utils/soup-lazy.ts); it is
|
|
28
|
+
* passed in rather than statically imported so this module does not link the
|
|
29
|
+
* `gi://Soup` typelib at import time.
|
|
26
30
|
*/
|
|
27
|
-
_appendToSoupMessage(message?: Soup.Message, type?: Soup.MessageHeadersType): Soup.MessageHeaders;
|
|
31
|
+
_appendToSoupMessage(soup: typeof Soup, message?: Soup.Message, type?: Soup.MessageHeadersType): Soup.MessageHeaders;
|
|
28
32
|
/**
|
|
29
33
|
* Create a Headers instance from a Soup.Message's headers.
|
|
30
34
|
*/
|
|
31
|
-
static _newFromSoupMessage(message: Soup.Message, type?: Soup.MessageHeadersType): Headers;
|
|
35
|
+
static _newFromSoupMessage(soup: typeof Soup, message: Soup.Message, type?: Soup.MessageHeadersType): Headers;
|
|
32
36
|
}
|
|
33
37
|
export {};
|
package/lib/headers.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// Adapted from node-fetch (https://github.com/node-fetch/node-fetch/blob/main/src/headers.js)
|
|
3
3
|
// Copyright (c) node-fetch contributors. MIT license.
|
|
4
4
|
// Modifications: Standalone implementation using internal Map, libsoup integration
|
|
5
|
-
import Soup from '@girs/soup-3.0';
|
|
6
5
|
import { validateHeaderName, validateHeaderValue } from '@gjsify/http/validators';
|
|
7
6
|
const _headers = Symbol('Headers.headers');
|
|
8
7
|
function isBoxedPrimitive(val) {
|
|
@@ -146,9 +145,13 @@ export default class Headers {
|
|
|
146
145
|
}
|
|
147
146
|
/**
|
|
148
147
|
* Append all headers to a Soup.Message for sending.
|
|
148
|
+
*
|
|
149
|
+
* `soup` is the lazily-loaded Soup namespace (see utils/soup-lazy.ts); it is
|
|
150
|
+
* passed in rather than statically imported so this module does not link the
|
|
151
|
+
* `gi://Soup` typelib at import time.
|
|
149
152
|
*/
|
|
150
|
-
_appendToSoupMessage(message, type =
|
|
151
|
-
const soupHeaders = message ? message.get_request_headers() : new
|
|
153
|
+
_appendToSoupMessage(soup, message, type = soup.MessageHeadersType.REQUEST) {
|
|
154
|
+
const soupHeaders = message ? message.get_request_headers() : new soup.MessageHeaders(type);
|
|
152
155
|
for (const [name, value] of this.entries()) {
|
|
153
156
|
soupHeaders.append(name, value);
|
|
154
157
|
}
|
|
@@ -157,10 +160,10 @@ export default class Headers {
|
|
|
157
160
|
/**
|
|
158
161
|
* Create a Headers instance from a Soup.Message's headers.
|
|
159
162
|
*/
|
|
160
|
-
static _newFromSoupMessage(message, type =
|
|
163
|
+
static _newFromSoupMessage(soup, message, type = soup.MessageHeadersType.RESPONSE) {
|
|
161
164
|
const headers = new Headers();
|
|
162
165
|
let soupHeaders;
|
|
163
|
-
if (type ===
|
|
166
|
+
if (type === soup.MessageHeadersType.RESPONSE) {
|
|
164
167
|
soupHeaders = message.get_response_headers();
|
|
165
168
|
}
|
|
166
169
|
else {
|
package/lib/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { clone } from './body.js';
|
|
|
10
10
|
import Response from './response.js';
|
|
11
11
|
import Headers from './headers.js';
|
|
12
12
|
import Request, { getSoupRequestOptions } from './request.js';
|
|
13
|
+
import { loadSoup } from './utils/soup-lazy.js';
|
|
13
14
|
import { FetchError } from './errors/fetch-error.js';
|
|
14
15
|
import { AbortError } from './errors/abort-error.js';
|
|
15
16
|
import { isRedirect } from './utils/is-redirect.js';
|
|
@@ -146,7 +147,10 @@ export default async function fetch(url, init = {}) {
|
|
|
146
147
|
// Error is consumed by the body when read
|
|
147
148
|
});
|
|
148
149
|
const message = request._message;
|
|
149
|
-
|
|
150
|
+
// Soup is already linked+cached by request._send above; loadSoup() returns
|
|
151
|
+
// the cached namespace so _newFromSoupMessage stays synchronous over it.
|
|
152
|
+
const soup = await loadSoup();
|
|
153
|
+
const headers = Headers._newFromSoupMessage(soup, message);
|
|
150
154
|
const statusCode = message.status_code;
|
|
151
155
|
const statusMessage = message.get_reason_phrase();
|
|
152
156
|
// HTTP fetch step 5 — handle redirects
|
package/lib/request.d.ts
CHANGED
package/lib/request.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// Copyright (c) node-fetch contributors. MIT license.
|
|
4
4
|
// Modifications: Rewritten for GJS using Soup.Message and Gio
|
|
5
5
|
import GLib from '@girs/glib-2.0';
|
|
6
|
-
import Soup from '@girs/soup-3.0';
|
|
7
6
|
import Gio from '@girs/gio-2.0';
|
|
8
7
|
import { soupSendAsync, inputStreamToReadable } from './utils/soup-helpers.js';
|
|
8
|
+
import { loadSoup } from './utils/soup-lazy.js';
|
|
9
9
|
import { URL } from '@gjsify/url';
|
|
10
10
|
import Headers from './headers.js';
|
|
11
11
|
import Body, { clone, extractContentType, getTotalBytes } from './body.js';
|
|
@@ -85,7 +85,7 @@ function shouldAcceptAnyCert(rejectUnauthorized) {
|
|
|
85
85
|
return false;
|
|
86
86
|
return GLib.getenv('NODE_TLS_REJECT_UNAUTHORIZED') === '0';
|
|
87
87
|
}
|
|
88
|
-
function getSharedSession() {
|
|
88
|
+
function getSharedSession(soup) {
|
|
89
89
|
if (sharedSession === null) {
|
|
90
90
|
const maxPerHost = envPositiveInt('GJSIFY_FETCH_MAX_CONNS_PER_HOST', DEFAULT_MAX_CONNS_PER_HOST);
|
|
91
91
|
// The global ceiling must be ≥ the per-host cap, else libsoup clamps
|
|
@@ -93,7 +93,7 @@ function getSharedSession() {
|
|
|
93
93
|
const maxConns = Math.max(envPositiveInt('GJSIFY_FETCH_MAX_CONNS', DEFAULT_MAX_CONNS), maxPerHost);
|
|
94
94
|
// `max-conns` / `max-conns-per-host` are construct-only in libsoup 3 —
|
|
95
95
|
// they MUST be passed to the constructor; the setters don't exist.
|
|
96
|
-
sharedSession = new
|
|
96
|
+
sharedSession = new soup.Session({
|
|
97
97
|
maxConns,
|
|
98
98
|
maxConnsPerHost: maxPerHost,
|
|
99
99
|
});
|
|
@@ -102,7 +102,7 @@ function getSharedSession() {
|
|
|
102
102
|
// double-decompression when index.ts also runs DecompressionStream.
|
|
103
103
|
// Remove it once here so our JS-level decompression handles everything.
|
|
104
104
|
// (No throw path: an absent feature is a documented silent no-op.)
|
|
105
|
-
sharedSession.remove_feature_by_type(
|
|
105
|
+
sharedSession.remove_feature_by_type(soup.ContentDecoder.$gtype);
|
|
106
106
|
}
|
|
107
107
|
return sharedSession;
|
|
108
108
|
}
|
|
@@ -263,32 +263,11 @@ export class Request extends Body {
|
|
|
263
263
|
else {
|
|
264
264
|
referrer = undefined;
|
|
265
265
|
}
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
// Reuse the process-wide shared session (see getSharedSession docs):
|
|
272
|
-
// a per-request session that gets GC-finalized while a connection is
|
|
273
|
-
// still registered on its host triggers libsoup's
|
|
274
|
-
// `host->conns == NULL` CRITICAL.
|
|
275
|
-
session = getSharedSession();
|
|
276
|
-
message = new Soup.Message({
|
|
277
|
-
method,
|
|
278
|
-
// ENCODED, not NONE: `parsedURL.toString()` is already a
|
|
279
|
-
// percent-encoded WHATWG URL. Parsing with NONE decodes it a
|
|
280
|
-
// second time, collapsing an escaped `%2F` in a path segment to
|
|
281
|
-
// a literal `/` — which sends `PUT /@scope/name` instead of the
|
|
282
|
-
// required `PUT /@scope%2Fname`. npm's registry tolerates the
|
|
283
|
-
// literal slash when UPDATING an existing package but 404s on
|
|
284
|
-
// the package-CREATE route and the OIDC token-exchange endpoint
|
|
285
|
-
// (`/-/npm/v1/oidc/token/exchange/package/@scope%2Fname`), which
|
|
286
|
-
// broke first-publish of new scoped packages + Trusted-Publisher
|
|
287
|
-
// OIDC auth once the CLI began running under GJS. ENCODED keeps
|
|
288
|
-
// the escaping intact on the wire.
|
|
289
|
-
uri: GLib.Uri.parse(parsedURL.toString(), GLib.UriFlags.ENCODED),
|
|
290
|
-
});
|
|
291
|
-
}
|
|
266
|
+
// Soup objects (session + message) are created lazily on first send
|
|
267
|
+
// (see `_send`), NOT here: constructing a Request — or merely importing
|
|
268
|
+
// `@gjsify/fetch` — must not link the `gi://Soup` typelib, so that a
|
|
269
|
+
// Soup-free command (e.g. `gjsify tsc`) does not require libsoup. Only
|
|
270
|
+
// http:/https: requests ever reach Soup; other schemes never do.
|
|
292
271
|
this[INTERNALS] = {
|
|
293
272
|
method,
|
|
294
273
|
redirect: init?.redirect || inputRL.redirect || 'follow',
|
|
@@ -297,8 +276,8 @@ export class Request extends Body {
|
|
|
297
276
|
signal,
|
|
298
277
|
referrer,
|
|
299
278
|
referrerPolicy: '',
|
|
300
|
-
session,
|
|
301
|
-
message,
|
|
279
|
+
session: null,
|
|
280
|
+
message: null,
|
|
302
281
|
};
|
|
303
282
|
// Node-fetch-only options
|
|
304
283
|
this.follow =
|
|
@@ -322,14 +301,41 @@ export class Request extends Body {
|
|
|
322
301
|
* Send the request using Soup.
|
|
323
302
|
*/
|
|
324
303
|
async _send(options) {
|
|
325
|
-
const {
|
|
326
|
-
|
|
304
|
+
const { signal, parsedURL } = this[INTERNALS];
|
|
305
|
+
const scheme = parsedURL.protocol;
|
|
306
|
+
if (scheme !== 'http:' && scheme !== 'https:') {
|
|
327
307
|
throw new Error('Cannot send request: no Soup session (non-HTTP URL?)');
|
|
328
308
|
}
|
|
309
|
+
// Link the Soup typelib now (dynamic import), deferred from construction
|
|
310
|
+
// so that importing @gjsify/fetch / building a Soup-free command never
|
|
311
|
+
// links libsoup — the typelib is loaded exactly when a request is sent.
|
|
312
|
+
const soup = await loadSoup();
|
|
313
|
+
// Reuse the process-wide shared session (see getSharedSession docs): a
|
|
314
|
+
// per-request session that gets GC-finalized while a connection is still
|
|
315
|
+
// registered on its host triggers libsoup's `host->conns == NULL`
|
|
316
|
+
// CRITICAL.
|
|
317
|
+
const session = getSharedSession(soup);
|
|
318
|
+
const message = new soup.Message({
|
|
319
|
+
method: this[INTERNALS].method,
|
|
320
|
+
// ENCODED, not NONE: `parsedURL.toString()` is already a
|
|
321
|
+
// percent-encoded WHATWG URL. Parsing with NONE decodes it a second
|
|
322
|
+
// time, collapsing an escaped `%2F` in a path segment to a literal
|
|
323
|
+
// `/` — which sends `PUT /@scope/name` instead of the required
|
|
324
|
+
// `PUT /@scope%2Fname`. npm's registry tolerates the literal slash
|
|
325
|
+
// when UPDATING an existing package but 404s on the package-CREATE
|
|
326
|
+
// route and the OIDC token-exchange endpoint
|
|
327
|
+
// (`/-/npm/v1/oidc/token/exchange/package/@scope%2Fname`), which
|
|
328
|
+
// broke first-publish of new scoped packages + Trusted-Publisher
|
|
329
|
+
// OIDC auth once the CLI began running under GJS. ENCODED keeps the
|
|
330
|
+
// escaping intact on the wire.
|
|
331
|
+
uri: GLib.Uri.parse(parsedURL.toString(), GLib.UriFlags.ENCODED),
|
|
332
|
+
});
|
|
333
|
+
this[INTERNALS].session = session;
|
|
334
|
+
this[INTERNALS].message = message;
|
|
329
335
|
// ContentDecoder is removed once on the shared session in
|
|
330
336
|
// getSharedSession() (so the Content-Encoding header survives for our
|
|
331
337
|
// JS-level DecompressionStream in index.ts). Nothing per-request here.
|
|
332
|
-
options.headers._appendToSoupMessage(message);
|
|
338
|
+
options.headers._appendToSoupMessage(soup, message);
|
|
333
339
|
// Attach the request body to the Soup message (needed for POST/PUT/PATCH).
|
|
334
340
|
// Use _rawBodyBuffer to read the body without consuming the stream (the
|
|
335
341
|
// `body` getter may have already put the internal Readable into flowing mode,
|
|
@@ -33,16 +33,40 @@ export default async () => {
|
|
|
33
33
|
const RequestCtor = globalThis.Request;
|
|
34
34
|
const fetchFn = globalThis.fetch;
|
|
35
35
|
await describe('@gjsify/fetch — Soup.Session lifecycle (host->conns regression)', async () => {
|
|
36
|
-
await it('reuses one shared Soup.Session across HTTP requests (no per-request session)', () => {
|
|
36
|
+
await it('reuses one shared Soup.Session across HTTP requests (no per-request session)', async () => {
|
|
37
37
|
// Two distinct HTTP Requests must share the very same Soup.Session
|
|
38
38
|
// instance. A per-request session is what triggered the GC race;
|
|
39
39
|
// a shared singleton can never be finalized while in use.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
//
|
|
41
|
+
// The session is created lazily on first SEND (deferred from the
|
|
42
|
+
// Request constructor so that importing @gjsify/fetch links no
|
|
43
|
+
// `gi://Soup` typelib — see utils/soup-lazy.ts). So drive two real
|
|
44
|
+
// sends against a local server and assert the invariant where the
|
|
45
|
+
// sessions actually live; `_session` is null before send.
|
|
46
|
+
const server = new Soup.Server({});
|
|
47
|
+
server.add_handler(null, (_s, msg) => {
|
|
48
|
+
msg.set_status(200, null);
|
|
49
|
+
msg.set_response('application/json', Soup.MemoryUse.COPY, new TextEncoder().encode('{}'));
|
|
50
|
+
});
|
|
51
|
+
server.listen_local(0, Soup.ServerListenOptions.IPV4_ONLY);
|
|
52
|
+
const base = server.get_uris()[0].to_string();
|
|
53
|
+
try {
|
|
54
|
+
const a = new RequestCtor(base);
|
|
55
|
+
const b = new RequestCtor(base);
|
|
56
|
+
// Before any send, no Soup object is allocated.
|
|
57
|
+
expect(a._session).toBeNull();
|
|
58
|
+
const ra = await a._send({ headers: a.headers });
|
|
59
|
+
const rb = await b._send({ headers: b.headers });
|
|
60
|
+
ra.readable.resume();
|
|
61
|
+
rb.readable.resume();
|
|
62
|
+
expect(a._session).toBeTruthy();
|
|
63
|
+
expect(b._session).toBeTruthy();
|
|
64
|
+
expect(a._session === b._session).toBe(true);
|
|
65
|
+
expect(a._session instanceof Soup.Session).toBe(true);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
server.disconnect();
|
|
69
|
+
}
|
|
46
70
|
});
|
|
47
71
|
await it('non-HTTP requests do not allocate a Soup.Session', () => {
|
|
48
72
|
const dataReq = new RequestCtor('data:text/plain,hi');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/fetch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Web and Node.js fetch module for Gjs",
|
|
5
5
|
"module": "lib/esm/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"fetch"
|
|
52
52
|
],
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@gjsify/cli": "^0.
|
|
55
|
-
"@gjsify/unit": "^0.
|
|
54
|
+
"@gjsify/cli": "^0.32.0",
|
|
55
|
+
"@gjsify/unit": "^0.32.0",
|
|
56
56
|
"@types/node": "^25.9.2",
|
|
57
57
|
"typescript": "^6.0.3"
|
|
58
58
|
},
|
|
@@ -61,11 +61,11 @@
|
|
|
61
61
|
"@girs/gjs": "^4.1.0",
|
|
62
62
|
"@girs/glib-2.0": "^4.1.0",
|
|
63
63
|
"@girs/soup-3.0": "^4.1.0",
|
|
64
|
-
"@gjsify/dom-events": "^0.
|
|
65
|
-
"@gjsify/formdata": "^0.
|
|
66
|
-
"@gjsify/http": "^0.
|
|
67
|
-
"@gjsify/url": "^0.
|
|
68
|
-
"@gjsify/utils": "^0.
|
|
64
|
+
"@gjsify/dom-events": "^0.32.0",
|
|
65
|
+
"@gjsify/formdata": "^0.32.0",
|
|
66
|
+
"@gjsify/http": "^0.32.0",
|
|
67
|
+
"@gjsify/url": "^0.32.0",
|
|
68
|
+
"@gjsify/utils": "^0.32.0"
|
|
69
69
|
},
|
|
70
70
|
"gjsify": {
|
|
71
71
|
"runtimes": {
|