@gjsify/fetch 0.4.29 → 0.4.31
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/request.js +1 -1
- package/lib/request.js +58 -11
- package/lib/soup-session.gjs.spec.d.ts +2 -0
- package/lib/soup-session.gjs.spec.js +118 -0
- package/lib/utils/referrer.d.ts +7 -1
- package/package.json +7 -7
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`)
|
|
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 getSharedSession(){if(g===null){g=new f.Session;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.NONE)}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.NONE)})),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 r=this._rawBodyBuffer;if(r!==null&&r.byteLength>0){let t=e.headers.get(`content-type`)||null;n.set_request_body_from_bytes(t,new p.Bytes(r))}let i=new m.Cancellable;return this[h].inputStream=await s(t,n,p.PRIORITY_DEFAULT,i),this[h].readable=o(this[h].inputStream),{inputStream:this[h].inputStream,readable:this[h].readable,cancellable:i}}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/request.js
CHANGED
|
@@ -12,6 +12,56 @@ import Body, { clone, extractContentType, getTotalBytes } from './body.js';
|
|
|
12
12
|
import { isAbortSignal } from './utils/is.js';
|
|
13
13
|
import { validateReferrerPolicy, determineRequestsReferrer, DEFAULT_REFERRER_POLICY } from './utils/referrer.js';
|
|
14
14
|
const INTERNALS = Symbol('Request internals');
|
|
15
|
+
/**
|
|
16
|
+
* Process-wide shared `Soup.Session`.
|
|
17
|
+
*
|
|
18
|
+
* Why a singleton and not a fresh `new Soup.Session()` per request:
|
|
19
|
+
*
|
|
20
|
+
* libsoup's connection manager keeps each open connection registered on a
|
|
21
|
+
* per-host list (`SoupHost.conns`). That list is only emptied when the
|
|
22
|
+
* connection's `disconnected` signal is processed on the main loop. When a
|
|
23
|
+
* `Soup.Session` is *finalized* its connection manager is torn down
|
|
24
|
+
* (`soup_connection_manager_free` → `soup_host_free`), which asserts the host
|
|
25
|
+
* has no live connections — `g_warn_if_fail (host->conns == NULL)`. If a
|
|
26
|
+
* session is dropped while a connection is still registered (the disconnect
|
|
27
|
+
* hasn't been pumped yet), libsoup prints:
|
|
28
|
+
*
|
|
29
|
+
* (gjs:…): libsoup-CRITICAL **: runtime check failed: (host->conns == NULL)
|
|
30
|
+
*
|
|
31
|
+
* Under GJS this is a SpiderMonkey-GC race: a per-request session (the old
|
|
32
|
+
* behavior — one `new Soup.Session()` per `Request`) becomes garbage as soon as
|
|
33
|
+
* the `Response` body is read, and a GC sweep can finalize it on the very same
|
|
34
|
+
* turn the connection is still being cleaned up. The same class of GLib/Soup
|
|
35
|
+
* BoxedInstance GC race is mitigated elsewhere in gjsify (e.g. `@gjsify/timers`
|
|
36
|
+
* uses `GLib.timeout_add` instead of holding `GLib.Source` boxed instances).
|
|
37
|
+
*
|
|
38
|
+
* Holding one long-lived session at module scope keeps it permanently reachable
|
|
39
|
+
* (never GC-finalized for the lifetime of the program), so the teardown path
|
|
40
|
+
* that fires the assertion is never reached mid-flight. It also lets libsoup
|
|
41
|
+
* pool and reuse keep-alive connections across requests — a real win for
|
|
42
|
+
* heavy fetch users like `@gjsify/npm-registry` during `gjsify install`.
|
|
43
|
+
*
|
|
44
|
+
* Lazily created so merely importing `@gjsify/fetch` (e.g. for `Headers`/
|
|
45
|
+
* `Request`/`Response` types on Node, or in a browser bundle) does not
|
|
46
|
+
* instantiate a Soup object.
|
|
47
|
+
*/
|
|
48
|
+
let sharedSession = null;
|
|
49
|
+
function getSharedSession() {
|
|
50
|
+
if (sharedSession === null) {
|
|
51
|
+
sharedSession = new Soup.Session();
|
|
52
|
+
// Soup auto-adds a ContentDecoder to new sessions, but it decodes the
|
|
53
|
+
// body without removing the Content-Encoding header, causing
|
|
54
|
+
// double-decompression when index.ts also runs DecompressionStream.
|
|
55
|
+
// Remove it once here so our JS-level decompression handles everything.
|
|
56
|
+
try {
|
|
57
|
+
sharedSession.remove_feature_by_type(Soup.ContentDecoder.$gtype);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* not present */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return sharedSession;
|
|
64
|
+
}
|
|
15
65
|
/**
|
|
16
66
|
* Check if `obj` is an instance of Request.
|
|
17
67
|
*/
|
|
@@ -165,7 +215,11 @@ export class Request extends Body {
|
|
|
165
215
|
let session = null;
|
|
166
216
|
let message = null;
|
|
167
217
|
if (scheme === 'http:' || scheme === 'https:') {
|
|
168
|
-
session
|
|
218
|
+
// Reuse the process-wide shared session (see getSharedSession docs):
|
|
219
|
+
// a per-request session that gets GC-finalized while a connection is
|
|
220
|
+
// still registered on its host triggers libsoup's
|
|
221
|
+
// `host->conns == NULL` CRITICAL.
|
|
222
|
+
session = getSharedSession();
|
|
169
223
|
message = new Soup.Message({
|
|
170
224
|
method,
|
|
171
225
|
uri: GLib.Uri.parse(parsedURL.toString(), GLib.UriFlags.NONE),
|
|
@@ -207,16 +261,9 @@ export class Request extends Body {
|
|
|
207
261
|
if (!session || !message) {
|
|
208
262
|
throw new Error('Cannot send request: no Soup session (non-HTTP URL?)');
|
|
209
263
|
}
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
// decompression in index.ts handles everything correctly.
|
|
214
|
-
try {
|
|
215
|
-
session.remove_feature_by_type(Soup.ContentDecoder.$gtype);
|
|
216
|
-
}
|
|
217
|
-
catch {
|
|
218
|
-
/* not present */
|
|
219
|
-
}
|
|
264
|
+
// ContentDecoder is removed once on the shared session in
|
|
265
|
+
// getSharedSession() (so the Content-Encoding header survives for our
|
|
266
|
+
// JS-level DecompressionStream in index.ts). Nothing per-request here.
|
|
220
267
|
options.headers._appendToSoupMessage(message);
|
|
221
268
|
// Attach the request body to the Soup message (needed for POST/PUT/PATCH).
|
|
222
269
|
// Use _rawBodyBuffer to read the body without consuming the stream (the
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Soup.Session lifecycle for GJS for @gjsify/fetch — original regression test.
|
|
2
|
+
//
|
|
3
|
+
// Regression: @gjsify/fetch used to create a fresh `new Soup.Session()` inside
|
|
4
|
+
// every Request constructor. Once the Response body was read, that per-request
|
|
5
|
+
// session became unreachable and SpiderMonkey could finalize it on the next GC
|
|
6
|
+
// sweep while a libsoup connection was still registered on its host. libsoup's
|
|
7
|
+
// connection-manager teardown then asserted `host->conns == NULL` and printed:
|
|
8
|
+
//
|
|
9
|
+
// (gjs:…): libsoup-CRITICAL **: runtime check failed: (host->conns == NULL)
|
|
10
|
+
//
|
|
11
|
+
// on stderr during heavy fetch use (notably @gjsify/npm-registry's packument/
|
|
12
|
+
// tarball fetches inside `gjsify install`). The fix holds ONE process-wide
|
|
13
|
+
// `Soup.Session` at module scope so it is never GC-finalized mid-flight.
|
|
14
|
+
//
|
|
15
|
+
// This file is GJS-only (`.gjs.spec.ts`). The suite body runs under
|
|
16
|
+
// `on('Gjs', …)`, so it is a no-op on Node. To keep the Node bundle free of
|
|
17
|
+
// `gi://*` / `system` imports (the same `test.mts` aggregator drives
|
|
18
|
+
// `test:node`), all GJS runtime objects are read from `globalThis.imports`
|
|
19
|
+
// (the GJS bootstrap) rather than statically imported — type-only imports give
|
|
20
|
+
// us the real shapes at compile time and are stripped at runtime.
|
|
21
|
+
import { describe, it, expect, on } from '@gjsify/unit';
|
|
22
|
+
export default async () => {
|
|
23
|
+
await on('Gjs', async () => {
|
|
24
|
+
// Read GJS runtime objects lazily (no static gi:// import → Node bundle
|
|
25
|
+
// stays clean). `imports` is the GJS bootstrap global.
|
|
26
|
+
const gjs = globalThis.imports;
|
|
27
|
+
const Soup = gjs.gi.Soup;
|
|
28
|
+
const GLib = gjs.gi.GLib;
|
|
29
|
+
const Gio = gjs.gi.Gio;
|
|
30
|
+
const System = gjs.system;
|
|
31
|
+
// Request + fetch are installed as globals by `@gjsify/fetch/register`,
|
|
32
|
+
// pulled into the test bundle by test.mts.
|
|
33
|
+
const RequestCtor = globalThis.Request;
|
|
34
|
+
const fetchFn = globalThis.fetch;
|
|
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)', () => {
|
|
37
|
+
// Two distinct HTTP Requests must share the very same Soup.Session
|
|
38
|
+
// instance. A per-request session is what triggered the GC race;
|
|
39
|
+
// a shared singleton can never be finalized while in use.
|
|
40
|
+
const a = new RequestCtor('http://example.com/a');
|
|
41
|
+
const b = new RequestCtor('https://example.org/b');
|
|
42
|
+
expect(a._session).toBeTruthy();
|
|
43
|
+
expect(b._session).toBeTruthy();
|
|
44
|
+
expect(a._session === b._session).toBe(true);
|
|
45
|
+
expect(a._session instanceof Soup.Session).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
await it('non-HTTP requests do not allocate a Soup.Session', () => {
|
|
48
|
+
const dataReq = new RequestCtor('data:text/plain,hi');
|
|
49
|
+
expect(dataReq._session).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
await it('many fetches + GC against a local server emit no host->conns / libsoup-CRITICAL warning', async () => {
|
|
52
|
+
// Exercise the live fetch path in-process first (the exact
|
|
53
|
+
// lifecycle: fetch → read body → drop refs → GC).
|
|
54
|
+
const server = new Soup.Server({});
|
|
55
|
+
server.add_handler(null, (_srv, msg) => {
|
|
56
|
+
msg.set_status(200, null);
|
|
57
|
+
msg.set_response('application/json', Soup.MemoryUse.COPY, new TextEncoder().encode('{"ok":true}'));
|
|
58
|
+
});
|
|
59
|
+
server.listen_local(0, Soup.ServerListenOptions.IPV4_ONLY);
|
|
60
|
+
const base = server.get_uris()[0].to_string();
|
|
61
|
+
for (let i = 0; i < 12; i++) {
|
|
62
|
+
const res = await fetchFn(base);
|
|
63
|
+
await res.json();
|
|
64
|
+
System.gc();
|
|
65
|
+
}
|
|
66
|
+
System.gc();
|
|
67
|
+
System.gc();
|
|
68
|
+
// Stronger assertion: run the same loop in a child gjs process and
|
|
69
|
+
// capture its stderr, so we can assert the libsoup warning is
|
|
70
|
+
// absent (GJS cannot redirect its own fd 2). The worker imports
|
|
71
|
+
// this package's built bundle by absolute path. If the bundle is
|
|
72
|
+
// not built (running specs from src only), skip the subprocess —
|
|
73
|
+
// the shared-session invariant above is the load-bearing check.
|
|
74
|
+
const pkgRoot = GLib.path_get_dirname(GLib.path_get_dirname(GLib.filename_from_uri(import.meta.url)[0]));
|
|
75
|
+
const fetchEntry = `${pkgRoot}/lib/esm/index.js`;
|
|
76
|
+
if (!GLib.file_test(fetchEntry, GLib.FileTest.EXISTS)) {
|
|
77
|
+
expect(true).toBe(true);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const worker = `
|
|
81
|
+
imports.gi.versions.Soup = '3.0';
|
|
82
|
+
const { Soup, GLib } = imports.gi;
|
|
83
|
+
const System = imports.system;
|
|
84
|
+
const { default: fetch } = await import(${JSON.stringify('file://' + fetchEntry)});
|
|
85
|
+
const server = new Soup.Server({});
|
|
86
|
+
server.add_handler(null, (_s, msg) => {
|
|
87
|
+
msg.set_status(200, null);
|
|
88
|
+
msg.set_response('application/json', Soup.MemoryUse.COPY,
|
|
89
|
+
new TextEncoder().encode('{"ok":true}'));
|
|
90
|
+
});
|
|
91
|
+
server.listen_local(0, Soup.ServerListenOptions.IPV4_ONLY);
|
|
92
|
+
const base = server.get_uris()[0].to_string();
|
|
93
|
+
for (let i = 0; i < 20; i++) {
|
|
94
|
+
const res = await fetch(base);
|
|
95
|
+
await res.json();
|
|
96
|
+
System.gc();
|
|
97
|
+
}
|
|
98
|
+
System.gc(); System.gc();
|
|
99
|
+
`;
|
|
100
|
+
const proc = Gio.Subprocess.new(['gjs', '-m', '-c', worker], Gio.SubprocessFlags.STDERR_PIPE | Gio.SubprocessFlags.STDOUT_SILENCE);
|
|
101
|
+
const stderrBytes = await new Promise((resolve, reject) => {
|
|
102
|
+
proc.communicate_async(null, null, (p, r) => {
|
|
103
|
+
try {
|
|
104
|
+
const [, , stderr] = p.communicate_finish(r);
|
|
105
|
+
resolve(stderr ? stderr.toArray() : new Uint8Array());
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
const stderr = new TextDecoder().decode(stderrBytes);
|
|
113
|
+
expect(stderr.includes('host->conns')).toBe(false);
|
|
114
|
+
expect(stderr.includes('libsoup-CRITICAL')).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
};
|
package/lib/utils/referrer.d.ts
CHANGED
|
@@ -13,7 +13,13 @@ import type Request from '../request.js';
|
|
|
13
13
|
* @param url
|
|
14
14
|
* @param originOnly
|
|
15
15
|
*/
|
|
16
|
-
export declare function stripURLForUseAsAReferrer(url: null | URL | 'no-referrer', originOnly?: boolean):
|
|
16
|
+
export declare function stripURLForUseAsAReferrer(url: null | URL | 'no-referrer', originOnly?: boolean): "no-referrer" | (URL & {
|
|
17
|
+
username: string;
|
|
18
|
+
password: string;
|
|
19
|
+
hash: string;
|
|
20
|
+
pathname: string;
|
|
21
|
+
search: string;
|
|
22
|
+
});
|
|
17
23
|
/**
|
|
18
24
|
* @see {@link https://w3c.github.io/webappsec-referrer-policy/#enumdef-referrerpolicy enum ReferrerPolicy}
|
|
19
25
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/fetch",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.31",
|
|
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",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"fetch"
|
|
49
49
|
],
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@gjsify/cli": "^0.4.
|
|
52
|
-
"@gjsify/unit": "^0.4.
|
|
51
|
+
"@gjsify/cli": "^0.4.31",
|
|
52
|
+
"@gjsify/unit": "^0.4.31",
|
|
53
53
|
"@types/node": "^25.9.1",
|
|
54
54
|
"typescript": "^6.0.3"
|
|
55
55
|
},
|
|
@@ -58,9 +58,9 @@
|
|
|
58
58
|
"@girs/gjs": "4.0.1",
|
|
59
59
|
"@girs/glib-2.0": "2.88.0-4.0.1",
|
|
60
60
|
"@girs/soup-3.0": "3.6.6-4.0.1",
|
|
61
|
-
"@gjsify/formdata": "^0.4.
|
|
62
|
-
"@gjsify/http": "^0.4.
|
|
63
|
-
"@gjsify/url": "^0.4.
|
|
64
|
-
"@gjsify/utils": "^0.4.
|
|
61
|
+
"@gjsify/formdata": "^0.4.31",
|
|
62
|
+
"@gjsify/http": "^0.4.31",
|
|
63
|
+
"@gjsify/url": "^0.4.31",
|
|
64
|
+
"@gjsify/utils": "^0.4.31"
|
|
65
65
|
}
|
|
66
66
|
}
|