@gjsify/fetch 0.4.44 → 0.5.1
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
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.
|
|
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 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};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import"../_virtual/_rolldown/runtime.js";import{File as e}from"./blob-from.js";import{FormData as t}from"@gjsify/formdata";
|
|
2
|
-
--`+e;let t=new Uint8Array(e.length);for(let n=0;n<e.length;n++)t[n]=e.charCodeAt(n),this.boundaryChars[t[n]]=!0;this.boundary=t,this.lookbehind=new Uint8Array(this.boundary.length+8)}write(e){let t=0,n=e.length,
|
|
1
|
+
import"../_virtual/_rolldown/runtime.js";import{File as e}from"./blob-from.js";import{FormData as t}from"@gjsify/formdata";var n=function(e){return e[e.START_BOUNDARY=0]=`START_BOUNDARY`,e[e.HEADER_FIELD_START=1]=`HEADER_FIELD_START`,e[e.HEADER_FIELD=2]=`HEADER_FIELD`,e[e.HEADER_VALUE_START=3]=`HEADER_VALUE_START`,e[e.HEADER_VALUE=4]=`HEADER_VALUE`,e[e.HEADER_VALUE_ALMOST_DONE=5]=`HEADER_VALUE_ALMOST_DONE`,e[e.HEADERS_ALMOST_DONE=6]=`HEADERS_ALMOST_DONE`,e[e.PART_DATA_START=7]=`PART_DATA_START`,e[e.PART_DATA=8]=`PART_DATA`,e[e.END=9]=`END`,e}(n||{});let r=1;const i={PART_BOUNDARY:r,LAST_BOUNDARY:r*=2},lower=e=>e|32,noop=(...e)=>{};var MultipartParser=class{index=0;flags=0;boundary;lookbehind;state=0;onHeaderEnd=noop;onHeaderField=noop;onHeadersEnd=noop;onHeaderValue=noop;onPartBegin=noop;onPartData=noop;onPartEnd=noop;boundaryChars={};constructor(e){e=`\r
|
|
2
|
+
--`+e;let t=new Uint8Array(e.length);for(let n=0;n<e.length;n++)t[n]=e.charCodeAt(n),this.boundaryChars[t[n]]=!0;this.boundary=t,this.lookbehind=new Uint8Array(this.boundary.length+8)}write(e){let t=0,n=e.length,r=this.index,{lookbehind:a,boundary:o,boundaryChars:s,index:c,state:l,flags:u}=this,d=this.boundary.length,f=d-1,p=e.length,m,h,mark=e=>{this[e+`Mark`]=t},clear=e=>{delete this[e+`Mark`]},callback=(e,t,n,r)=>{(t===void 0||t!==n)&&this[e](r&&r.subarray(t,n))},dataCallback=(n,r=!1)=>{let i=n+`Mark`;i in this&&(r?(callback(n,this[i],t,e),delete this[i]):(callback(n,this[i],e.length,e),this[i]=0))};for(t=0;t<n;t++)switch(m=e[t],l){case 0:if(c===o.length-2){if(m===45)u|=i.LAST_BOUNDARY;else if(m!==13)return;c++;break}else if(c-1==o.length-2){if(u&i.LAST_BOUNDARY&&m===45)l=9,u=0;else if(!(u&i.LAST_BOUNDARY)&&m===10)c=0,callback(`onPartBegin`),l=1;else return;break}m!==o[c+2]&&(c=-2),m===o[c+2]&&c++;break;case 1:l=2,mark(`onHeaderField`),c=0;case 2:if(m===13){clear(`onHeaderField`),l=6;break}if(c++,m===45)break;if(m===58){if(c===1)return;dataCallback(`onHeaderField`,!0),l=3;break}if(h=lower(m),h<97||h>122)return;break;case 3:if(m===32)break;mark(`onHeaderValue`),l=4;case 4:m===13&&(dataCallback(`onHeaderValue`,!0),callback(`onHeaderEnd`),l=5);break;case 5:if(m!==10)return;l=1;break;case 6:if(m!==10)return;callback(`onHeadersEnd`),l=7;break;case 7:l=8,mark(`onPartData`);case 8:if(r=c,c===0){for(t+=f;t<p&&!(e[t]in s);)t+=d;t-=f,m=e[t]}if(c<o.length)o[c]===m?(c===0&&dataCallback(`onPartData`,!0),c++):c=0;else if(c===o.length)c++,m===13?u|=i.PART_BOUNDARY:m===45?u|=i.LAST_BOUNDARY:c=0;else if(c-1===o.length)if(u&i.PART_BOUNDARY){if(c=0,m===10){u&=~i.PART_BOUNDARY,callback(`onPartEnd`),callback(`onPartBegin`),l=1;break}}else u&i.LAST_BOUNDARY&&m===45?(callback(`onPartEnd`),l=9,u=0):c=0;if(c>0)a[c-1]=m;else if(r>0){let e=new Uint8Array(a.buffer,a.byteOffset,a.byteLength);callback(`onPartData`,0,r,e),r=0,mark(`onPartData`),t--}break;case 9:break;default:throw Error(`Unexpected state entered: ${l}`)}dataCallback(`onHeaderField`),dataCallback(`onHeaderValue`),dataCallback(`onPartData`),this.index=c,this.state=l,this.flags=u}end(){if(this.state===1&&this.index===0||this.state===8&&this.index===this.boundary.length)this.onPartEnd();else if(this.state!==9)throw Error(`MultipartParser.end(): stream ended unexpectedly`)}};function _fileName(e){let t=e.match(/\bfilename=("(.*?)"|([^()<>@,;:\\"/[\]?={}\s\t]+))($|;\s)/i);if(!t)return;let n=t[2]||t[3]||``,r=n.slice(n.lastIndexOf(`\\`)+1);return r=r.replace(/%22/g,`"`),r=r.replace(/&#(\d{4});/g,(e,t)=>String.fromCharCode(t)),r}async function toFormData(n,r){if(!/multipart/i.test(r))throw TypeError(`Failed to fetch`);let i=r.match(/boundary=(?:"([^"]+)"|([^;]+))/i);if(!i)throw TypeError(`no or bad content-type header, no multipart boundary`);let a=new MultipartParser(i[1]||i[2]),o,s,c,l,u,d,f=[],p=new t,onPartData=e=>{c+=m.decode(e,{stream:!0})},appendToFile=e=>{f.push(e)},appendFileToFormData=()=>{let t=new e(f,d,{type:u});p.append(l,t)},appendEntryToFormData=()=>{p.append(l,c)},m=new TextDecoder(`utf-8`);m.decode(),a.onPartBegin=function(){a.onPartData=onPartData,a.onPartEnd=appendEntryToFormData,o=``,s=``,c=``,l=``,u=``,d=null,f.length=0},a.onHeaderField=function(e){o+=m.decode(e,{stream:!0})},a.onHeaderValue=function(e){s+=m.decode(e,{stream:!0})},a.onHeaderEnd=function(){if(s+=m.decode(),o=o.toLowerCase(),o===`content-disposition`){let e=s.match(/\bname=("([^"]*)"|([^()<>@,;:\\"/[\]?={}\s\t]+))/i);e&&(l=e[2]||e[3]||``),d=_fileName(s),d&&(a.onPartData=appendToFile,a.onPartEnd=appendFileToFormData)}else o===`content-type`&&(u=s);s=``,o=``};for await(let e of n)a.write(e);return a.end(),p}export{toFormData};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Response-body PARTIAL_INPUT regression for @gjsify/fetch — GJS-only.
|
|
2
|
+
//
|
|
3
|
+
// Bug: fetch() failed to read SOME response bodies with
|
|
4
|
+
// Gio.IOErrorEnum: Weitere Eingaben erforderlich (G_IO_ERROR_PARTIAL_INPUT)
|
|
5
|
+
// Norman's accounting API responses tripped it while Qonto/Paperless were fine,
|
|
6
|
+
// and the exact same code path worked on Node. The trigger is a *response shape*
|
|
7
|
+
// the libsoup/zlib body reader mishandles at the tail — not the endpoint.
|
|
8
|
+
//
|
|
9
|
+
// Two independent places raise G_IO_ERROR_PARTIAL_INPUT (code 34) at end-of-body
|
|
10
|
+
// even though the full payload has already been produced:
|
|
11
|
+
//
|
|
12
|
+
// 1. Soup's chunked/length body input stream, when the server closes the
|
|
13
|
+
// socket without the terminating `0\r\n\r\n` chunk (or sends fewer bytes
|
|
14
|
+
// than a declared Content-Length). Surfaces as
|
|
15
|
+
// "Die Verbindung wurde unerwartet beendet" but matches PARTIAL_INPUT.
|
|
16
|
+
//
|
|
17
|
+
// 2. Gio.ZlibDecompressor (the GConverter backing node:zlib's gunzip/inflate,
|
|
18
|
+
// reached via fetch's DecompressionStream branch for gzip/deflate
|
|
19
|
+
// Content-Encoding) when the compressed stream's trailer (gzip CRC32+ISIZE
|
|
20
|
+
// / zlib adler tail) is missing — it has already emitted 100% of the
|
|
21
|
+
// decompressed bytes. THIS is the literal "Weitere Eingaben erforderlich".
|
|
22
|
+
//
|
|
23
|
+
// A real server that closes the keep-alive connection a touch early (or omits
|
|
24
|
+
// the chunk terminator under load) hits both. The body reader must treat a
|
|
25
|
+
// tail-only PARTIAL_INPUT as a clean EOF and return the bytes already delivered,
|
|
26
|
+
// exactly as Node's lenient stream/zlib paths do.
|
|
27
|
+
//
|
|
28
|
+
// This suite drives the REAL fetch() against a raw Gio.SocketService server so
|
|
29
|
+
// every byte on the wire is controlled. No external host is contacted.
|
|
30
|
+
//
|
|
31
|
+
// GJS-only (`.gjs.spec.ts`): the body runs under on('Gjs', …) and reads all GJS
|
|
32
|
+
// runtime objects from globalThis.imports so the Node bundle (same test.mts
|
|
33
|
+
// aggregator) never resolves gi://*.
|
|
34
|
+
import { describe, it, expect, on } from '@gjsify/unit';
|
|
35
|
+
const enc = (s) => new TextEncoder().encode(s);
|
|
36
|
+
function concatBytes(...arrs) {
|
|
37
|
+
const total = arrs.reduce((n, a) => n + a.length, 0);
|
|
38
|
+
const out = new Uint8Array(total);
|
|
39
|
+
let off = 0;
|
|
40
|
+
for (const a of arrs) {
|
|
41
|
+
out.set(a, off);
|
|
42
|
+
off += a.length;
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
export default async () => {
|
|
47
|
+
await on('Gjs', async () => {
|
|
48
|
+
const gjs = globalThis.imports;
|
|
49
|
+
const GLib = gjs.gi.GLib;
|
|
50
|
+
const Gio = gjs.gi.Gio;
|
|
51
|
+
const fetchFn = globalThis.fetch;
|
|
52
|
+
/** gzip `bytes` via Gio so the test does not depend on the polyfill it tests. */
|
|
53
|
+
const gzip = (bytes) => {
|
|
54
|
+
const comp = Gio.ZlibCompressor.new(Gio.ZlibCompressorFormat.GZIP, -1);
|
|
55
|
+
const mem = Gio.MemoryOutputStream.new_resizable();
|
|
56
|
+
const cs = Gio.ConverterOutputStream.new(mem, comp);
|
|
57
|
+
cs.write_all(bytes, null);
|
|
58
|
+
cs.flush(null);
|
|
59
|
+
cs.close(null);
|
|
60
|
+
return mem.steal_as_bytes().toArray().slice();
|
|
61
|
+
};
|
|
62
|
+
const deflate = (bytes) => {
|
|
63
|
+
const comp = Gio.ZlibCompressor.new(Gio.ZlibCompressorFormat.ZLIB, -1);
|
|
64
|
+
const mem = Gio.MemoryOutputStream.new_resizable();
|
|
65
|
+
const cs = Gio.ConverterOutputStream.new(mem, comp);
|
|
66
|
+
cs.write_all(bytes, null);
|
|
67
|
+
cs.flush(null);
|
|
68
|
+
cs.close(null);
|
|
69
|
+
return mem.steal_as_bytes().toArray().slice();
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Start a raw TCP server that answers the first (and only) request on each
|
|
73
|
+
* connection with exactly `responseBytes`, then closes the socket. Returns
|
|
74
|
+
* the bound origin and a stop() fn. The framing is entirely up to the
|
|
75
|
+
* caller — that is the whole point.
|
|
76
|
+
*/
|
|
77
|
+
const startRawServer = (responseBytes) => {
|
|
78
|
+
const service = new Gio.SocketService();
|
|
79
|
+
const port = service.add_any_inet_port(null);
|
|
80
|
+
service.connect('incoming', (_svc, conn) => {
|
|
81
|
+
const input = conn.get_input_stream();
|
|
82
|
+
const output = conn.get_output_stream();
|
|
83
|
+
// Drain the request headers, then write the canned bytes and close.
|
|
84
|
+
input.read_bytes_async(65536, GLib.PRIORITY_DEFAULT, null, (s, r) => {
|
|
85
|
+
try {
|
|
86
|
+
s.read_bytes_finish(r);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* ignore request-read errors */
|
|
90
|
+
}
|
|
91
|
+
output.write_bytes_async(new GLib.Bytes(responseBytes), GLib.PRIORITY_DEFAULT, null, (os, or) => {
|
|
92
|
+
try {
|
|
93
|
+
os.write_bytes_finish(or);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* ignore */
|
|
97
|
+
}
|
|
98
|
+
// Close WITHOUT any graceful HTTP framing beyond what the
|
|
99
|
+
// caller put in responseBytes.
|
|
100
|
+
try {
|
|
101
|
+
conn.close(null);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* ignore */
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
return false;
|
|
109
|
+
});
|
|
110
|
+
service.start();
|
|
111
|
+
const base = `http://127.0.0.1:${port}/`;
|
|
112
|
+
return {
|
|
113
|
+
base,
|
|
114
|
+
stop: () => {
|
|
115
|
+
try {
|
|
116
|
+
service.stop();
|
|
117
|
+
service.close();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
/* ignore */
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
const PAYLOAD = JSON.stringify({
|
|
126
|
+
hello: 'world',
|
|
127
|
+
items: [1, 2, 3, 4, 5],
|
|
128
|
+
// Long enough that gzip actually produces a multi-byte deflate block.
|
|
129
|
+
note: 'gjsify-fetch-partial-input-'.repeat(40),
|
|
130
|
+
});
|
|
131
|
+
// A payload whose GZIPPED form exceeds the body reader's 4096-byte read
|
|
132
|
+
// size, so the compressed stream is delivered to DecompressionStream in
|
|
133
|
+
// SEVERAL chunks. This is the primary Norman trigger: stateless per-chunk
|
|
134
|
+
// decompression breaks here (the first chunk alone is not a complete gzip
|
|
135
|
+
// stream) while native Node keeps inflate state across chunks. The body
|
|
136
|
+
// mixes structure + varied text so gzip can't collapse it below 4 KB.
|
|
137
|
+
const bigItems = [];
|
|
138
|
+
for (let i = 0; i < 500; i++) {
|
|
139
|
+
bigItems.push({
|
|
140
|
+
id: i,
|
|
141
|
+
name: `transaction ${i}`,
|
|
142
|
+
amount: (i * 3.14159).toFixed(2),
|
|
143
|
+
memo: `Rechnung Nr ${i} für Leistung im Monat ${(i % 12) + 1}`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const BIG_PAYLOAD = JSON.stringify({ ok: true, items: bigItems });
|
|
147
|
+
await describe('@gjsify/fetch — response body PARTIAL_INPUT at EOF (Norman regression)', async () => {
|
|
148
|
+
await it('reads a chunked body terminated cleanly (baseline)', async () => {
|
|
149
|
+
const body = enc('HTTP/1.1 200 OK\r\n' +
|
|
150
|
+
'Content-Type: application/json\r\n' +
|
|
151
|
+
'Transfer-Encoding: chunked\r\n' +
|
|
152
|
+
'\r\n' +
|
|
153
|
+
PAYLOAD.length.toString(16) +
|
|
154
|
+
'\r\n' +
|
|
155
|
+
PAYLOAD +
|
|
156
|
+
'\r\n0\r\n\r\n');
|
|
157
|
+
const srv = startRawServer(body);
|
|
158
|
+
try {
|
|
159
|
+
const res = await fetchFn(srv.base);
|
|
160
|
+
const text = await res.text();
|
|
161
|
+
expect(text).toBe(PAYLOAD);
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
srv.stop();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
await it('reads a chunked body whose terminating 0-chunk never arrives', async () => {
|
|
168
|
+
// Server streamed the whole payload then dropped the connection
|
|
169
|
+
// without `0\r\n\r\n`. Soup raises PARTIAL_INPUT on the final read;
|
|
170
|
+
// all bytes were already delivered, so .text() must still be whole.
|
|
171
|
+
const body = enc('HTTP/1.1 200 OK\r\n' +
|
|
172
|
+
'Content-Type: application/json\r\n' +
|
|
173
|
+
'Transfer-Encoding: chunked\r\n' +
|
|
174
|
+
'\r\n' +
|
|
175
|
+
PAYLOAD.length.toString(16) +
|
|
176
|
+
'\r\n' +
|
|
177
|
+
PAYLOAD);
|
|
178
|
+
const srv = startRawServer(body);
|
|
179
|
+
try {
|
|
180
|
+
const res = await fetchFn(srv.base);
|
|
181
|
+
const json = (await res.json());
|
|
182
|
+
expect(json.hello).toBe('world');
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
srv.stop();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
await it('reads a Content-Length body that closes one frame early', async () => {
|
|
189
|
+
const body = enc('HTTP/1.1 200 OK\r\n' +
|
|
190
|
+
'Content-Type: application/json\r\n' +
|
|
191
|
+
'Content-Length: ' +
|
|
192
|
+
(PAYLOAD.length + 16) + // server lies: promises more than it sends
|
|
193
|
+
'\r\n\r\n' +
|
|
194
|
+
PAYLOAD);
|
|
195
|
+
const srv = startRawServer(body);
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetchFn(srv.base);
|
|
198
|
+
const text = await res.text();
|
|
199
|
+
expect(text).toBe(PAYLOAD);
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
srv.stop();
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
await it('reads a read-to-close body (no length, Connection: close)', async () => {
|
|
206
|
+
const body = enc('HTTP/1.1 200 OK\r\n' +
|
|
207
|
+
'Content-Type: application/json\r\n' +
|
|
208
|
+
'Connection: close\r\n' +
|
|
209
|
+
'\r\n' +
|
|
210
|
+
PAYLOAD);
|
|
211
|
+
const srv = startRawServer(body);
|
|
212
|
+
try {
|
|
213
|
+
const res = await fetchFn(srv.base);
|
|
214
|
+
const text = await res.text();
|
|
215
|
+
expect(text).toBe(PAYLOAD);
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
srv.stop();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
await it('decodes a clean gzip body (Content-Encoding: gzip, chunked)', async () => {
|
|
222
|
+
const gz = gzip(enc(PAYLOAD));
|
|
223
|
+
const body = concatBytes(enc('HTTP/1.1 200 OK\r\n' +
|
|
224
|
+
'Content-Type: application/json\r\n' +
|
|
225
|
+
'Content-Encoding: gzip\r\n' +
|
|
226
|
+
'Transfer-Encoding: chunked\r\n' +
|
|
227
|
+
'\r\n' +
|
|
228
|
+
gz.length.toString(16) +
|
|
229
|
+
'\r\n'), gz, enc('\r\n0\r\n\r\n'));
|
|
230
|
+
const srv = startRawServer(body);
|
|
231
|
+
try {
|
|
232
|
+
const res = await fetchFn(srv.base);
|
|
233
|
+
const text = await res.text();
|
|
234
|
+
expect(text).toBe(PAYLOAD);
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
srv.stop();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
await it('decodes a gzip body that spans multiple read chunks', async () => {
|
|
241
|
+
// The primary Norman shape: a complete, well-framed gzip response
|
|
242
|
+
// whose compressed size (> 4 KB) forces the body reader to deliver
|
|
243
|
+
// it in several chunks. Per-chunk decompression throws
|
|
244
|
+
// "Ungültige komprimierte Daten" / "Weitere Eingaben erforderlich"
|
|
245
|
+
// here; the streaming decode must reassemble the full payload.
|
|
246
|
+
const gz = gzip(enc(BIG_PAYLOAD));
|
|
247
|
+
expect(gz.length > 4096).toBe(true); // guard: actually multi-chunk
|
|
248
|
+
const body = concatBytes(enc('HTTP/1.1 200 OK\r\n' +
|
|
249
|
+
'Content-Type: application/json\r\n' +
|
|
250
|
+
'Content-Encoding: gzip\r\n' +
|
|
251
|
+
'Content-Length: ' +
|
|
252
|
+
gz.length +
|
|
253
|
+
'\r\n\r\n'), gz);
|
|
254
|
+
const srv = startRawServer(body);
|
|
255
|
+
try {
|
|
256
|
+
const res = await fetchFn(srv.base);
|
|
257
|
+
const text = await res.text();
|
|
258
|
+
expect(text.length).toBe(BIG_PAYLOAD.length);
|
|
259
|
+
expect(text).toBe(BIG_PAYLOAD);
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
srv.stop();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
await it('decodes a deflate body that spans multiple read chunks', async () => {
|
|
266
|
+
// Same multi-chunk reassembly for Content-Encoding: deflate.
|
|
267
|
+
const df = deflate(enc(BIG_PAYLOAD));
|
|
268
|
+
expect(df.length > 4096).toBe(true);
|
|
269
|
+
const body = concatBytes(enc('HTTP/1.1 200 OK\r\n' +
|
|
270
|
+
'Content-Type: application/json\r\n' +
|
|
271
|
+
'Content-Encoding: deflate\r\n' +
|
|
272
|
+
'Content-Length: ' +
|
|
273
|
+
df.length +
|
|
274
|
+
'\r\n\r\n'), df);
|
|
275
|
+
const srv = startRawServer(body);
|
|
276
|
+
try {
|
|
277
|
+
const res = await fetchFn(srv.base);
|
|
278
|
+
const text = await res.text();
|
|
279
|
+
expect(text).toBe(BIG_PAYLOAD);
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
srv.stop();
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
};
|
package/lib/request.js
CHANGED
|
@@ -164,7 +164,14 @@ export class Request extends Body {
|
|
|
164
164
|
return this[INTERNALS].parsedURL.toString();
|
|
165
165
|
}
|
|
166
166
|
get _uri() {
|
|
167
|
-
|
|
167
|
+
// ENCODED, not NONE: `this.url` (a WHATWG-serialized URL) is already
|
|
168
|
+
// percent-encoded, so re-parsing with NONE would DECODE it a second
|
|
169
|
+
// time — turning an intentionally-escaped `%2F` in a path segment back
|
|
170
|
+
// into a literal `/`. That breaks scoped-package registry routes like
|
|
171
|
+
// `PUT /@scope%2Fname` (npm's package-create + OIDC token-exchange
|
|
172
|
+
// endpoints 404 on the decoded `/@scope/name` form). ENCODED preserves
|
|
173
|
+
// the existing escaping on round-trip. See request.ts uri construction.
|
|
174
|
+
return GLib.Uri.parse(this.url, GLib.UriFlags.ENCODED);
|
|
168
175
|
}
|
|
169
176
|
get _session() {
|
|
170
177
|
return this[INTERNALS].session;
|
|
@@ -258,7 +265,18 @@ export class Request extends Body {
|
|
|
258
265
|
session = getSharedSession();
|
|
259
266
|
message = new Soup.Message({
|
|
260
267
|
method,
|
|
261
|
-
|
|
268
|
+
// ENCODED, not NONE: `parsedURL.toString()` is already a
|
|
269
|
+
// percent-encoded WHATWG URL. Parsing with NONE decodes it a
|
|
270
|
+
// second time, collapsing an escaped `%2F` in a path segment to
|
|
271
|
+
// a literal `/` — which sends `PUT /@scope/name` instead of the
|
|
272
|
+
// required `PUT /@scope%2Fname`. npm's registry tolerates the
|
|
273
|
+
// literal slash when UPDATING an existing package but 404s on
|
|
274
|
+
// the package-CREATE route and the OIDC token-exchange endpoint
|
|
275
|
+
// (`/-/npm/v1/oidc/token/exchange/package/@scope%2Fname`), which
|
|
276
|
+
// broke first-publish of new scoped packages + Trusted-Publisher
|
|
277
|
+
// OIDC auth once the CLI began running under GJS. ENCODED keeps
|
|
278
|
+
// the escaping intact on the wire.
|
|
279
|
+
uri: GLib.Uri.parse(parsedURL.toString(), GLib.UriFlags.ENCODED),
|
|
262
280
|
});
|
|
263
281
|
}
|
|
264
282
|
this[INTERNALS] = {
|
|
@@ -114,5 +114,23 @@ export default async () => {
|
|
|
114
114
|
expect(stderr.includes('libsoup-CRITICAL')).toBe(false);
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
|
+
// Regression: @gjsify/fetch re-parsed the already-encoded request URL
|
|
118
|
+
// with `GLib.UriFlags.NONE`, which DECODES a second time — collapsing an
|
|
119
|
+
// escaped `%2F` inside a path segment back to a literal `/`. That sent
|
|
120
|
+
// `PUT /@scope/name` instead of the required `PUT /@scope%2Fname`, so
|
|
121
|
+
// npm's package-CREATE route and the OIDC token-exchange endpoint
|
|
122
|
+
// (`/-/npm/v1/oidc/token/exchange/package/@scope%2Fname`) 404'd — only
|
|
123
|
+
// visible once the CLI began running under GJS (the Node path preserved
|
|
124
|
+
// it). Fix: parse with `GLib.UriFlags.ENCODED`. See request.ts `_uri` +
|
|
125
|
+
// Soup.Message construction.
|
|
126
|
+
await describe('@gjsify/fetch — percent-encoded path segments (scoped-registry %2F)', async () => {
|
|
127
|
+
await it('preserves %2F in the request URI instead of decoding it to a literal /', () => {
|
|
128
|
+
const req = new RequestCtor('https://registry.npmjs.org/@gjsify%2Ffetch');
|
|
129
|
+
const uriStr = req._uri.to_string();
|
|
130
|
+
// %2F (any hex case) must survive; the decoded literal-slash form must NOT appear.
|
|
131
|
+
expect(/@gjsify%2[Ff]fetch/.test(uriStr)).toBe(true);
|
|
132
|
+
expect(/@gjsify\/fetch/.test(uriStr)).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
117
135
|
});
|
|
118
136
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/fetch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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,9 +51,9 @@
|
|
|
51
51
|
"fetch"
|
|
52
52
|
],
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@gjsify/cli": "^0.
|
|
55
|
-
"@gjsify/unit": "^0.
|
|
56
|
-
"@types/node": "^25.9.
|
|
54
|
+
"@gjsify/cli": "^0.5.1",
|
|
55
|
+
"@gjsify/unit": "^0.5.1",
|
|
56
|
+
"@types/node": "^25.9.2",
|
|
57
57
|
"typescript": "^6.0.3"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
@@ -61,10 +61,10 @@
|
|
|
61
61
|
"@girs/gjs": "4.0.4",
|
|
62
62
|
"@girs/glib-2.0": "2.88.0-4.0.4",
|
|
63
63
|
"@girs/soup-3.0": "3.6.6-4.0.4",
|
|
64
|
-
"@gjsify/formdata": "^0.
|
|
65
|
-
"@gjsify/http": "^0.
|
|
66
|
-
"@gjsify/url": "^0.
|
|
67
|
-
"@gjsify/utils": "^0.
|
|
64
|
+
"@gjsify/formdata": "^0.5.1",
|
|
65
|
+
"@gjsify/http": "^0.5.1",
|
|
66
|
+
"@gjsify/url": "^0.5.1",
|
|
67
|
+
"@gjsify/utils": "^0.5.1"
|
|
68
68
|
},
|
|
69
69
|
"gjsify": {
|
|
70
70
|
"runtimes": {
|