@gjsify/fetch 0.1.13 → 0.2.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/body.d.ts CHANGED
@@ -29,6 +29,8 @@ export default class Body {
29
29
  });
30
30
  get body(): ReadableStream<Uint8Array> | null;
31
31
  get _stream(): Readable;
32
+ /** Return the raw body buffer without consuming the stream (used by Request._send). */
33
+ get _rawBodyBuffer(): Buffer | null;
32
34
  get bodyUsed(): boolean;
33
35
  /**
34
36
  * Decode response as ArrayBuffer
package/lib/body.js CHANGED
@@ -124,19 +124,41 @@ export default class Body {
124
124
  return null;
125
125
  // If ReadableStream is available, wrap the Readable into one
126
126
  if (typeof ReadableStream !== 'undefined') {
127
+ let closed = false;
127
128
  return new ReadableStream({
128
129
  start(controller) {
129
130
  stream.on('data', (chunk) => {
130
- controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
131
+ if (closed)
132
+ return;
133
+ try {
134
+ controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
135
+ }
136
+ catch { /* consumer cancelled — drop */ }
131
137
  });
132
138
  stream.on('end', () => {
133
- controller.close();
139
+ if (closed)
140
+ return;
141
+ closed = true;
142
+ // Defensive: consumer may have cancelled the stream already,
143
+ // in which case .close() throws TypeError. Don't surface that
144
+ // as an unhandled error in the nextTick queue.
145
+ try {
146
+ controller.close();
147
+ }
148
+ catch { /* already closed/cancelled */ }
134
149
  });
135
150
  stream.on('error', (err) => {
136
- controller.error(err);
151
+ if (closed)
152
+ return;
153
+ closed = true;
154
+ try {
155
+ controller.error(err);
156
+ }
157
+ catch { /* already closed/cancelled */ }
137
158
  });
138
159
  },
139
160
  cancel() {
161
+ closed = true;
140
162
  stream.destroy();
141
163
  }
142
164
  });
@@ -146,6 +168,17 @@ export default class Body {
146
168
  get _stream() {
147
169
  return this[INTERNALS].stream;
148
170
  }
171
+ /** Return the raw body buffer without consuming the stream (used by Request._send). */
172
+ get _rawBodyBuffer() {
173
+ const b = this[INTERNALS].body;
174
+ if (b === null)
175
+ return null;
176
+ if (Buffer.isBuffer(b))
177
+ return b;
178
+ if (b instanceof Uint8Array)
179
+ return Buffer.from(b.buffer, b.byteOffset, b.byteLength);
180
+ return null;
181
+ }
149
182
  get bodyUsed() {
150
183
  return this[INTERNALS].disturbed;
151
184
  }
package/lib/esm/body.js CHANGED
@@ -77,19 +77,35 @@ class Body {
77
77
  const stream = this[INTERNALS].stream;
78
78
  if (!stream) return null;
79
79
  if (typeof ReadableStream !== "undefined") {
80
+ let closed = false;
80
81
  return new ReadableStream({
81
82
  start(controller) {
82
83
  stream.on("data", (chunk) => {
83
- controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
84
+ if (closed) return;
85
+ try {
86
+ controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
87
+ } catch {
88
+ }
84
89
  });
85
90
  stream.on("end", () => {
86
- controller.close();
91
+ if (closed) return;
92
+ closed = true;
93
+ try {
94
+ controller.close();
95
+ } catch {
96
+ }
87
97
  });
88
98
  stream.on("error", (err) => {
89
- controller.error(err);
99
+ if (closed) return;
100
+ closed = true;
101
+ try {
102
+ controller.error(err);
103
+ } catch {
104
+ }
90
105
  });
91
106
  },
92
107
  cancel() {
108
+ closed = true;
93
109
  stream.destroy();
94
110
  }
95
111
  });
@@ -99,6 +115,14 @@ class Body {
99
115
  get _stream() {
100
116
  return this[INTERNALS].stream;
101
117
  }
118
+ /** Return the raw body buffer without consuming the stream (used by Request._send). */
119
+ get _rawBodyBuffer() {
120
+ const b = this[INTERNALS].body;
121
+ if (b === null) return null;
122
+ if (Buffer.isBuffer(b)) return b;
123
+ if (b instanceof Uint8Array) return Buffer.from(b.buffer, b.byteOffset, b.byteLength);
124
+ return null;
125
+ }
102
126
  get bodyUsed() {
103
127
  return this[INTERNALS].disturbed;
104
128
  }
package/lib/esm/index.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  File
17
17
  } from "./utils/blob-from.js";
18
18
  import { URL } from "@gjsify/url";
19
+ import { XMLHttpRequest, XMLHttpRequestUpload } from "./xhr.js";
19
20
  const supportedSchemas = /* @__PURE__ */ new Set(["data:", "http:", "https:", "file:"]);
20
21
  function rewriteRootRelativeUrl(input) {
21
22
  if (typeof input !== "string") return input;
@@ -94,7 +95,7 @@ async function fetch(url, init = {}) {
94
95
  signal.removeEventListener("abort", abortHandler);
95
96
  }
96
97
  };
97
- cancellable.connect("cancelled", () => {
98
+ cancellable.connect(() => {
98
99
  readable.destroy(new AbortError("The operation was aborted."));
99
100
  });
100
101
  readable.on("error", (error) => {
@@ -209,6 +210,8 @@ export {
209
210
  Headers,
210
211
  Request,
211
212
  Response,
213
+ XMLHttpRequest,
214
+ XMLHttpRequestUpload,
212
215
  fetch as default,
213
216
  isRedirect
214
217
  };
@@ -0,0 +1,7 @@
1
+ import { XMLHttpRequest, XMLHttpRequestUpload } from "../xhr.js";
2
+ if (typeof globalThis.XMLHttpRequest === "undefined") {
3
+ globalThis.XMLHttpRequest = XMLHttpRequest;
4
+ }
5
+ if (typeof globalThis.XMLHttpRequestUpload === "undefined") {
6
+ globalThis.XMLHttpRequestUpload = XMLHttpRequestUpload;
7
+ }
@@ -1 +1,2 @@
1
1
  import "./register/fetch.js";
2
+ import "./register/xhr.js";
@@ -181,7 +181,16 @@ class Request extends Body {
181
181
  if (!session || !message) {
182
182
  throw new Error("Cannot send request: no Soup session (non-HTTP URL?)");
183
183
  }
184
+ try {
185
+ session.remove_feature_by_type(Soup.ContentDecoder.$gtype);
186
+ } catch {
187
+ }
184
188
  options.headers._appendToSoupMessage(message);
189
+ const rawBuf = this._rawBodyBuffer;
190
+ if (rawBuf !== null && rawBuf.byteLength > 0) {
191
+ const contentType = options.headers.get("content-type") || null;
192
+ message.set_request_body_from_bytes(contentType, new GLib.Bytes(rawBuf));
193
+ }
185
194
  const cancellable = new Gio.Cancellable();
186
195
  this[INTERNALS].inputStream = await soupSendAsync(session, message, GLib.PRIORITY_DEFAULT, cancellable);
187
196
  this[INTERNALS].readable = inputStreamToReadable(this[INTERNALS].inputStream);
@@ -258,7 +267,7 @@ const getSoupRequestOptions = (request) => {
258
267
  headers.set("User-Agent", "gjsify-fetch");
259
268
  }
260
269
  if (request.compress && !headers.has("Accept-Encoding")) {
261
- headers.set("Accept-Encoding", "gzip, deflate, br");
270
+ headers.set("Accept-Encoding", "gzip, deflate");
262
271
  }
263
272
  let { agent } = request;
264
273
  if (typeof agent === "function") {
package/lib/esm/xhr.js ADDED
@@ -0,0 +1,258 @@
1
+ import GLib from "gi://GLib?version=2.0";
2
+ import fetch from "./index.js";
3
+ let _blobCounter = 0;
4
+ function guessBlobExt(url) {
5
+ const lower = url.toLowerCase().split("?")[0];
6
+ const dot = lower.lastIndexOf(".");
7
+ const ext = dot > -1 ? lower.slice(dot) : "";
8
+ switch (ext) {
9
+ case ".png":
10
+ case ".jpg":
11
+ case ".jpeg":
12
+ case ".gif":
13
+ case ".webp":
14
+ case ".svg":
15
+ case ".bmp":
16
+ case ".ttf":
17
+ case ".otf":
18
+ case ".woff":
19
+ case ".woff2":
20
+ case ".mp3":
21
+ case ".wav":
22
+ case ".ogg":
23
+ case ".flac":
24
+ case ".m4a":
25
+ case ".mp4":
26
+ case ".webm":
27
+ case ".mkv":
28
+ case ".xml":
29
+ case ".tmx":
30
+ case ".json":
31
+ return ext;
32
+ default:
33
+ return ".bin";
34
+ }
35
+ }
36
+ function writeBlobToTempFile(bytes, url) {
37
+ const tmpPath = GLib.build_filenamev([
38
+ GLib.get_tmp_dir(),
39
+ `gjsify-blob-${_blobCounter++}${guessBlobExt(url)}`
40
+ ]);
41
+ GLib.file_set_contents(tmpPath, bytes);
42
+ return tmpPath;
43
+ }
44
+ const UNSENT = 0;
45
+ const OPENED = 1;
46
+ const HEADERS_RECEIVED = 2;
47
+ const LOADING = 3;
48
+ const DONE = 4;
49
+ class XMLHttpRequest extends EventTarget {
50
+ static UNSENT = UNSENT;
51
+ static OPENED = OPENED;
52
+ static HEADERS_RECEIVED = HEADERS_RECEIVED;
53
+ static LOADING = LOADING;
54
+ static DONE = DONE;
55
+ UNSENT = UNSENT;
56
+ OPENED = OPENED;
57
+ HEADERS_RECEIVED = HEADERS_RECEIVED;
58
+ LOADING = LOADING;
59
+ DONE = DONE;
60
+ readyState = UNSENT;
61
+ status = 0;
62
+ statusText = "";
63
+ responseType = "";
64
+ responseText = "";
65
+ response = null;
66
+ responseURL = "";
67
+ withCredentials = false;
68
+ timeout = 0;
69
+ upload = new XMLHttpRequestUpload();
70
+ onreadystatechange = null;
71
+ onload = null;
72
+ onerror = null;
73
+ onabort = null;
74
+ ontimeout = null;
75
+ onloadstart = null;
76
+ onloadend = null;
77
+ onprogress = null;
78
+ _method = "GET";
79
+ _url = "";
80
+ _headers = /* @__PURE__ */ new Map();
81
+ _responseHeaders = /* @__PURE__ */ new Map();
82
+ _controller = new AbortController();
83
+ _aborted = false;
84
+ _timeoutId = null;
85
+ open(method, url, _async = true, _user, _password) {
86
+ this._method = method.toUpperCase();
87
+ this._url = url;
88
+ this._headers.clear();
89
+ this._responseHeaders.clear();
90
+ this._aborted = false;
91
+ this._controller = new AbortController();
92
+ this._setReadyState(OPENED);
93
+ }
94
+ setRequestHeader(header, value) {
95
+ if (this.readyState < OPENED) throw new DOMException("Must open first", "InvalidStateError");
96
+ this._headers.set(header.toLowerCase(), value);
97
+ }
98
+ getResponseHeader(header) {
99
+ return this._responseHeaders.get(header.toLowerCase()) ?? null;
100
+ }
101
+ getAllResponseHeaders() {
102
+ const lines = [];
103
+ this._responseHeaders.forEach((v, k) => lines.push(`${k}: ${v}`));
104
+ return lines.join("\r\n");
105
+ }
106
+ send(body) {
107
+ if (this.readyState !== OPENED) throw new DOMException("Must open first", "InvalidStateError");
108
+ if (this._aborted) return;
109
+ const headersInit = {};
110
+ this._headers.forEach((v, k) => {
111
+ headersInit[k] = v;
112
+ });
113
+ const fetchOptions = {
114
+ method: this._method,
115
+ headers: headersInit,
116
+ credentials: this.withCredentials ? "include" : "omit",
117
+ signal: this._controller.signal
118
+ };
119
+ if (body != null && this._method !== "GET" && this._method !== "HEAD") {
120
+ fetchOptions.body = body;
121
+ }
122
+ if (this.timeout > 0) {
123
+ this._timeoutId = setTimeout(() => {
124
+ this._controller.abort();
125
+ this._onTimeout();
126
+ }, this.timeout);
127
+ }
128
+ this.dispatchEvent(new Event("loadstart"));
129
+ if (this.onloadstart) this.onloadstart(new ProgressEvent("loadstart"));
130
+ fetch(this._url, fetchOptions).then(async (res) => {
131
+ if (this._aborted) return;
132
+ if (this._timeoutId) {
133
+ clearTimeout(this._timeoutId);
134
+ this._timeoutId = null;
135
+ }
136
+ this.status = res.status;
137
+ this.statusText = res.statusText;
138
+ this.responseURL = res.url;
139
+ res.headers.forEach((v, k) => {
140
+ this._responseHeaders.set(k.toLowerCase(), v);
141
+ });
142
+ this._setReadyState(HEADERS_RECEIVED);
143
+ this._setReadyState(LOADING);
144
+ switch (this.responseType) {
145
+ case "arraybuffer": {
146
+ const ab = await res.arrayBuffer();
147
+ this.response = ab;
148
+ this.responseText = "";
149
+ break;
150
+ }
151
+ case "blob": {
152
+ const ab = await res.arrayBuffer();
153
+ const bytes = new Uint8Array(ab);
154
+ const tmpPath = writeBlobToTempFile(bytes, this._url);
155
+ const blob = new Blob([ab], {
156
+ type: this._responseHeaders.get("content-type") ?? ""
157
+ });
158
+ blob._tmpPath = tmpPath;
159
+ this.response = blob;
160
+ this.responseText = "";
161
+ break;
162
+ }
163
+ case "json": {
164
+ const text = await res.text();
165
+ this.responseText = "";
166
+ try {
167
+ this.response = text.length > 0 ? JSON.parse(text) : null;
168
+ } catch {
169
+ this.response = null;
170
+ }
171
+ break;
172
+ }
173
+ case "document": {
174
+ const text = await res.text();
175
+ this.responseText = text;
176
+ this.response = text;
177
+ break;
178
+ }
179
+ case "":
180
+ case "text":
181
+ default: {
182
+ const text = await res.text();
183
+ const stripped = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
184
+ this.responseText = stripped;
185
+ this.response = stripped;
186
+ break;
187
+ }
188
+ }
189
+ this._setReadyState(DONE);
190
+ this.dispatchEvent(new ProgressEvent("load"));
191
+ this.dispatchEvent(new ProgressEvent("loadend"));
192
+ if (this.onload) this.onload(new ProgressEvent("load"));
193
+ if (this.onloadend) this.onloadend(new ProgressEvent("loadend"));
194
+ }).catch((_err) => {
195
+ if (this._timeoutId) {
196
+ clearTimeout(this._timeoutId);
197
+ this._timeoutId = null;
198
+ }
199
+ if (this._aborted) return;
200
+ this._setReadyState(DONE);
201
+ const ev = new ProgressEvent("error");
202
+ this.dispatchEvent(ev);
203
+ if (this.onerror) this.onerror(ev);
204
+ if (this.onloadend) this.onloadend(new ProgressEvent("loadend"));
205
+ });
206
+ }
207
+ abort() {
208
+ if (this._aborted) return;
209
+ this._aborted = true;
210
+ if (this._timeoutId) {
211
+ clearTimeout(this._timeoutId);
212
+ this._timeoutId = null;
213
+ }
214
+ this._controller.abort();
215
+ if (this.readyState !== UNSENT && this.readyState !== DONE) {
216
+ this._setReadyState(DONE);
217
+ this.status = 0;
218
+ }
219
+ const ev = new ProgressEvent("abort");
220
+ this.dispatchEvent(ev);
221
+ if (this.onabort) this.onabort(ev);
222
+ if (this.onloadend) this.onloadend(new ProgressEvent("loadend"));
223
+ }
224
+ overrideMimeType(_mime) {
225
+ }
226
+ _onTimeout() {
227
+ if (this._aborted) return;
228
+ this._aborted = true;
229
+ this._setReadyState(DONE);
230
+ const ev = new ProgressEvent("timeout");
231
+ this.dispatchEvent(ev);
232
+ if (this.ontimeout) this.ontimeout(ev);
233
+ }
234
+ _setReadyState(state) {
235
+ this.readyState = state;
236
+ const ev = new Event("readystatechange");
237
+ this.dispatchEvent(ev);
238
+ if (this.onreadystatechange) this.onreadystatechange(ev);
239
+ }
240
+ }
241
+ class XMLHttpRequestUpload extends EventTarget {
242
+ onprogress = null;
243
+ onloadstart = null;
244
+ onloadend = null;
245
+ onload = null;
246
+ onerror = null;
247
+ onabort = null;
248
+ ontimeout = null;
249
+ }
250
+ export {
251
+ DONE,
252
+ HEADERS_RECEIVED,
253
+ LOADING,
254
+ OPENED,
255
+ UNSENT,
256
+ XMLHttpRequest,
257
+ XMLHttpRequestUpload
258
+ };
package/lib/index.d.ts CHANGED
@@ -9,6 +9,7 @@ import { Blob, File } from './utils/blob-from.js';
9
9
  import { URL } from '@gjsify/url';
10
10
  export { FormData, Headers, Request, Response, FetchError, AbortError, isRedirect };
11
11
  export { Blob, File };
12
+ export { XMLHttpRequest, XMLHttpRequestUpload } from './xhr.js';
12
13
  /**
13
14
  * Fetch function
14
15
  *
package/lib/index.js CHANGED
@@ -19,6 +19,7 @@ import { Blob, File, } from './utils/blob-from.js';
19
19
  import { URL } from '@gjsify/url';
20
20
  export { FormData, Headers, Request, Response, FetchError, AbortError, isRedirect };
21
21
  export { Blob, File };
22
+ export { XMLHttpRequest, XMLHttpRequestUpload } from './xhr.js';
22
23
  const supportedSchemas = new Set(['data:', 'http:', 'https:', 'file:']);
23
24
  /**
24
25
  * Rewrite root-relative URLs (e.g. `/res/images/foo.png`) to `file://` relative
@@ -141,8 +142,10 @@ export default async function fetch(url, init = {}) {
141
142
  signal.removeEventListener('abort', abortHandler);
142
143
  }
143
144
  };
144
- // Listen for cancellation
145
- cancellable.connect('cancelled', () => {
145
+ // Listen for cancellation.
146
+ // Gio.Cancellable.connect() is g_cancellable_connect() pass callback + DestroyNotify (or null).
147
+ // NOT a GObject signal: do NOT pass a signal name as the first argument.
148
+ cancellable.connect(() => {
146
149
  readable.destroy(new AbortError('The operation was aborted.'));
147
150
  });
148
151
  // Handle stream errors
package/lib/index.spec.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect, on } from '@gjsify/unit';
2
- import fetch, { Headers, Request, Response } from 'fetch';
2
+ // fetch / Headers / Request / Response / FormData are accessed off globalThis:
3
+ // on Node they are native, on GJS they are installed by
4
+ // `@gjsify/fetch/register` (pulled in automatically by `--globals auto`).
5
+ // XMLHttpRequest is GJS-only and is likewise read from globalThis inside an
6
+ // on('Gjs', …) gate further below.
3
7
  export default async () => {
4
8
  await describe('fetch', async () => {
5
9
  await it('fetch should be a function', async () => {
@@ -146,9 +150,14 @@ export default async () => {
146
150
  expect(clone.method).toBe('POST');
147
151
  expect(clone.headers.get('x-test')).toBe('value');
148
152
  });
149
- await it('should create request with null body', async () => {
150
- const r = new Request('https://example.com', { body: null });
151
- expect(r.body).toBeNull();
153
+ // Firefox returns a non-null body for new Request(url, { body: null }) likely
154
+ // an empty ReadableStream contrary to the spec (body: null → r.body === null).
155
+ // Guard to Node.js + GJS where spec-correct behavior is verified.
156
+ await on(['Node.js', 'Gjs'], async () => {
157
+ await it('should create request with null body', async () => {
158
+ const r = new Request('https://example.com', { body: null });
159
+ expect(r.body).toBeNull();
160
+ });
152
161
  });
153
162
  });
154
163
  await describe('Response', async () => {
@@ -235,4 +244,50 @@ export default async () => {
235
244
  expect(text).toBe('hello');
236
245
  });
237
246
  });
247
+ await on('Gjs', async () => {
248
+ await describe('XMLHttpRequest responseType', async () => {
249
+ // Regression: Excalibur.js sets responseType='arraybuffer' for audio and
250
+ // 'blob' for images. Before this fix XHR ignored responseType and always
251
+ // returned text, which crashed gst_memory_new_wrapped on audio decode and
252
+ // broke URL.createObjectURL on image load.
253
+ //
254
+ // XMLHttpRequest is accessed off globalThis (not imported). On GJS it's
255
+ // registered by `@gjsify/fetch/register/xhr` (pulled in by --globals
256
+ // auto when the detector sees `new XMLHttpRequest()`); on Node there
257
+ // is no native XMLHttpRequest, so the suite is gated with on('Gjs', …).
258
+ const XHR = globalThis.XMLHttpRequest;
259
+ const runXhr = (init) => new Promise((resolve, reject) => {
260
+ const xhr = new XHR();
261
+ xhr.open('GET', 'data:text/plain;base64,aGVsbG8=');
262
+ init(xhr);
263
+ xhr.onload = () => resolve(xhr);
264
+ xhr.onerror = () => reject(new Error('xhr error'));
265
+ xhr.send();
266
+ });
267
+ await it('responseType="arraybuffer" yields ArrayBuffer', async () => {
268
+ const xhr = await runXhr((x) => { x.responseType = 'arraybuffer'; });
269
+ expect(xhr.response instanceof ArrayBuffer).toBe(true);
270
+ expect(xhr.response.byteLength).toBe(5);
271
+ });
272
+ await it('responseType="text" yields decoded string', async () => {
273
+ const xhr = await runXhr((x) => { x.responseType = 'text'; });
274
+ expect(xhr.response).toBe('hello');
275
+ expect(xhr.responseText).toBe('hello');
276
+ });
277
+ await it('default responseType "" yields text', async () => {
278
+ const xhr = await runXhr(() => { });
279
+ expect(xhr.response).toBe('hello');
280
+ expect(xhr.responseText).toBe('hello');
281
+ });
282
+ await it('responseType="blob" attaches _tmpPath for URL.createObjectURL', async () => {
283
+ const xhr = await runXhr((x) => { x.responseType = 'blob'; });
284
+ const blob = xhr.response;
285
+ expect(blob instanceof Blob).toBe(true);
286
+ expect(typeof blob._tmpPath).toBe('string');
287
+ const url = URL.createObjectURL(blob);
288
+ expect(url.startsWith('file://')).toBe(true);
289
+ URL.revokeObjectURL(url);
290
+ });
291
+ });
292
+ });
238
293
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ // Registers XMLHttpRequest / XMLHttpRequestUpload on globalThis.
2
+ //
3
+ // The Blob → file:// URL chain required by Excalibur's ImageSource / FontFace
4
+ // is split across two packages:
5
+ // - `@gjsify/fetch` XHR (this package): when responseType='blob', materialise
6
+ // the response to a GLib temp file and attach `_tmpPath` to the returned
7
+ // Blob.
8
+ // - `@gjsify/url` URL class: `URL.createObjectURL(blob)` reads `_tmpPath`
9
+ // and returns a `file://` URL that HTMLImageElement etc. can load.
10
+ //
11
+ // There is no URL monkey-patching here — URL owns createObjectURL natively.
12
+ import { XMLHttpRequest, XMLHttpRequestUpload } from '../xhr.js';
13
+ if (typeof globalThis.XMLHttpRequest === 'undefined') {
14
+ globalThis.XMLHttpRequest = XMLHttpRequest;
15
+ }
16
+ if (typeof globalThis.XMLHttpRequestUpload === 'undefined') {
17
+ globalThis.XMLHttpRequestUpload = XMLHttpRequestUpload;
18
+ }
package/lib/register.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  import './register/fetch.js';
2
+ import './register/xhr.js';
package/lib/register.js CHANGED
@@ -1,3 +1,4 @@
1
- // Catch-all side-effect module: registers fetch/Headers/Request/Response.
2
- // For granular imports use '@gjsify/fetch/register/fetch'.
1
+ // Catch-all side-effect module: registers fetch/Headers/Request/Response + XMLHttpRequest.
2
+ // For granular imports use '@gjsify/fetch/register/fetch' or '@gjsify/fetch/register/xhr'.
3
3
  import './register/fetch.js';
4
+ import './register/xhr.js';
package/lib/request.js CHANGED
@@ -200,7 +200,24 @@ export class Request extends Body {
200
200
  if (!session || !message) {
201
201
  throw new Error('Cannot send request: no Soup session (non-HTTP URL?)');
202
202
  }
203
+ // Soup auto-adds ContentDecoder to new sessions, but it decodes the body
204
+ // without removing the Content-Encoding header, causing double-decompression
205
+ // if we also run DecompressionStream below. Remove it so our JS-level
206
+ // decompression in index.ts handles everything correctly.
207
+ try {
208
+ session.remove_feature_by_type(Soup.ContentDecoder.$gtype);
209
+ }
210
+ catch { /* not present */ }
203
211
  options.headers._appendToSoupMessage(message);
212
+ // Attach the request body to the Soup message (needed for POST/PUT/PATCH).
213
+ // Use _rawBodyBuffer to read the body without consuming the stream (the
214
+ // `body` getter may have already put the internal Readable into flowing mode,
215
+ // draining its buffer before we get here).
216
+ const rawBuf = this._rawBodyBuffer;
217
+ if (rawBuf !== null && rawBuf.byteLength > 0) {
218
+ const contentType = options.headers.get('content-type') || null;
219
+ message.set_request_body_from_bytes(contentType, new GLib.Bytes(rawBuf));
220
+ }
204
221
  const cancellable = new Gio.Cancellable();
205
222
  this[INTERNALS].inputStream = await soupSendAsync(session, message, GLib.PRIORITY_DEFAULT, cancellable);
206
223
  this[INTERNALS].readable = inputStreamToReadable(this[INTERNALS].inputStream);
@@ -287,9 +304,11 @@ export const getSoupRequestOptions = (request) => {
287
304
  if (!headers.has('User-Agent')) {
288
305
  headers.set('User-Agent', 'gjsify-fetch');
289
306
  }
290
- // HTTP-network-or-cache fetch step 2.15
307
+ // HTTP-network-or-cache fetch step 2.15. Brotli ('br') is omitted: SpiderMonkey
308
+ // 128's DecompressionStream supports only 'gzip' and 'deflate', so advertising
309
+ // 'br' would let servers respond with bodies we cannot decode.
291
310
  if (request.compress && !headers.has('Accept-Encoding')) {
292
- headers.set('Accept-Encoding', 'gzip, deflate, br');
311
+ headers.set('Accept-Encoding', 'gzip, deflate');
293
312
  }
294
313
  let { agent } = request;
295
314
  if (typeof agent === 'function') {