@gjsify/fetch 0.1.15 → 0.3.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/xhr.d.ts ADDED
@@ -0,0 +1,62 @@
1
+ export declare const UNSENT = 0;
2
+ export declare const OPENED = 1;
3
+ export declare const HEADERS_RECEIVED = 2;
4
+ export declare const LOADING = 3;
5
+ export declare const DONE = 4;
6
+ type EventHandler = ((event: ProgressEvent) => void) | null;
7
+ export declare class XMLHttpRequest extends EventTarget {
8
+ static UNSENT: number;
9
+ static OPENED: number;
10
+ static HEADERS_RECEIVED: number;
11
+ static LOADING: number;
12
+ static DONE: number;
13
+ readonly UNSENT = 0;
14
+ readonly OPENED = 1;
15
+ readonly HEADERS_RECEIVED = 2;
16
+ readonly LOADING = 3;
17
+ readonly DONE = 4;
18
+ readyState: number;
19
+ status: number;
20
+ statusText: string;
21
+ responseType: string;
22
+ responseText: string;
23
+ response: unknown;
24
+ responseURL: string;
25
+ withCredentials: boolean;
26
+ timeout: number;
27
+ upload: XMLHttpRequestUpload;
28
+ onreadystatechange: ((event: Event) => void) | null;
29
+ onload: EventHandler;
30
+ onerror: EventHandler;
31
+ onabort: EventHandler;
32
+ ontimeout: EventHandler;
33
+ onloadstart: EventHandler;
34
+ onloadend: EventHandler;
35
+ onprogress: EventHandler;
36
+ private _method;
37
+ private _url;
38
+ private _headers;
39
+ private _responseHeaders;
40
+ private _controller;
41
+ private _aborted;
42
+ private _timeoutId;
43
+ open(method: string, url: string, _async?: boolean, _user?: string | null, _password?: string | null): void;
44
+ setRequestHeader(header: string, value: string): void;
45
+ getResponseHeader(header: string): string | null;
46
+ getAllResponseHeaders(): string;
47
+ send(body?: Document | XMLHttpRequestBodyInit | null): void;
48
+ abort(): void;
49
+ overrideMimeType(_mime: string): void;
50
+ private _onTimeout;
51
+ private _setReadyState;
52
+ }
53
+ export declare class XMLHttpRequestUpload extends EventTarget {
54
+ onprogress: EventHandler;
55
+ onloadstart: EventHandler;
56
+ onloadend: EventHandler;
57
+ onload: EventHandler;
58
+ onerror: EventHandler;
59
+ onabort: EventHandler;
60
+ ontimeout: EventHandler;
61
+ }
62
+ export {};
package/lib/xhr.js ADDED
@@ -0,0 +1,278 @@
1
+ // XMLHttpRequest — fetch()-backed XHR for GJS.
2
+ // Covers engine.io-client (polling XHR) and Excalibur.js (ImageSource/Sound)
3
+ // use cases. For `responseType === 'blob'` the bytes are written to a GLib
4
+ // temp file and the returned Blob carries a `_tmpPath` that
5
+ // `URL.createObjectURL` turns into a `file://` URL, so that HTMLImageElement
6
+ // / Image / HTMLAudioElement can stream the asset through GdkPixbuf / Gst.
7
+ //
8
+ // Reference: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest
9
+ import GLib from 'gi://GLib?version=2.0';
10
+ import fetch from './index.js';
11
+ let _blobCounter = 0;
12
+ function guessBlobExt(url) {
13
+ const lower = url.toLowerCase().split('?')[0];
14
+ const dot = lower.lastIndexOf('.');
15
+ const ext = dot > -1 ? lower.slice(dot) : '';
16
+ // Restrict to a small safe allow-list; unknown → .bin
17
+ switch (ext) {
18
+ case '.png':
19
+ case '.jpg':
20
+ case '.jpeg':
21
+ case '.gif':
22
+ case '.webp':
23
+ case '.svg':
24
+ case '.bmp':
25
+ case '.ttf':
26
+ case '.otf':
27
+ case '.woff':
28
+ case '.woff2':
29
+ case '.mp3':
30
+ case '.wav':
31
+ case '.ogg':
32
+ case '.flac':
33
+ case '.m4a':
34
+ case '.mp4':
35
+ case '.webm':
36
+ case '.mkv':
37
+ case '.xml':
38
+ case '.tmx':
39
+ case '.json':
40
+ return ext;
41
+ default:
42
+ return '.bin';
43
+ }
44
+ }
45
+ function writeBlobToTempFile(bytes, url) {
46
+ const tmpPath = GLib.build_filenamev([
47
+ GLib.get_tmp_dir(),
48
+ `gjsify-blob-${_blobCounter++}${guessBlobExt(url)}`,
49
+ ]);
50
+ GLib.file_set_contents(tmpPath, bytes);
51
+ return tmpPath;
52
+ }
53
+ export const UNSENT = 0;
54
+ export const OPENED = 1;
55
+ export const HEADERS_RECEIVED = 2;
56
+ export const LOADING = 3;
57
+ export const DONE = 4;
58
+ export class XMLHttpRequest extends EventTarget {
59
+ static UNSENT = UNSENT;
60
+ static OPENED = OPENED;
61
+ static HEADERS_RECEIVED = HEADERS_RECEIVED;
62
+ static LOADING = LOADING;
63
+ static DONE = DONE;
64
+ UNSENT = UNSENT;
65
+ OPENED = OPENED;
66
+ HEADERS_RECEIVED = HEADERS_RECEIVED;
67
+ LOADING = LOADING;
68
+ DONE = DONE;
69
+ readyState = UNSENT;
70
+ status = 0;
71
+ statusText = '';
72
+ responseType = '';
73
+ responseText = '';
74
+ response = null;
75
+ responseURL = '';
76
+ withCredentials = false;
77
+ timeout = 0;
78
+ upload = new XMLHttpRequestUpload();
79
+ onreadystatechange = null;
80
+ onload = null;
81
+ onerror = null;
82
+ onabort = null;
83
+ ontimeout = null;
84
+ onloadstart = null;
85
+ onloadend = null;
86
+ onprogress = null;
87
+ _method = 'GET';
88
+ _url = '';
89
+ _headers = new Map();
90
+ _responseHeaders = new Map();
91
+ _controller = new AbortController();
92
+ _aborted = false;
93
+ _timeoutId = null;
94
+ open(method, url, _async = true, _user, _password) {
95
+ this._method = method.toUpperCase();
96
+ this._url = url;
97
+ this._headers.clear();
98
+ this._responseHeaders.clear();
99
+ this._aborted = false;
100
+ this._controller = new AbortController();
101
+ this._setReadyState(OPENED);
102
+ }
103
+ setRequestHeader(header, value) {
104
+ if (this.readyState < OPENED)
105
+ throw new DOMException('Must open first', 'InvalidStateError');
106
+ this._headers.set(header.toLowerCase(), value);
107
+ }
108
+ getResponseHeader(header) {
109
+ return this._responseHeaders.get(header.toLowerCase()) ?? null;
110
+ }
111
+ getAllResponseHeaders() {
112
+ const lines = [];
113
+ this._responseHeaders.forEach((v, k) => lines.push(`${k}: ${v}`));
114
+ return lines.join('\r\n');
115
+ }
116
+ send(body) {
117
+ if (this.readyState !== OPENED)
118
+ throw new DOMException('Must open first', 'InvalidStateError');
119
+ if (this._aborted)
120
+ return;
121
+ const headersInit = {};
122
+ this._headers.forEach((v, k) => { headersInit[k] = v; });
123
+ const fetchOptions = {
124
+ method: this._method,
125
+ headers: headersInit,
126
+ credentials: this.withCredentials ? 'include' : 'omit',
127
+ signal: this._controller.signal,
128
+ };
129
+ if (body != null && this._method !== 'GET' && this._method !== 'HEAD') {
130
+ fetchOptions.body = body;
131
+ }
132
+ if (this.timeout > 0) {
133
+ this._timeoutId = setTimeout(() => {
134
+ this._controller.abort();
135
+ this._onTimeout();
136
+ }, this.timeout);
137
+ }
138
+ this.dispatchEvent(new Event('loadstart'));
139
+ if (this.onloadstart)
140
+ this.onloadstart(new ProgressEvent('loadstart'));
141
+ fetch(this._url, fetchOptions)
142
+ .then(async (res) => {
143
+ if (this._aborted)
144
+ return;
145
+ if (this._timeoutId) {
146
+ clearTimeout(this._timeoutId);
147
+ this._timeoutId = null;
148
+ }
149
+ this.status = res.status;
150
+ this.statusText = res.statusText;
151
+ this.responseURL = res.url;
152
+ res.headers.forEach((v, k) => {
153
+ this._responseHeaders.set(k.toLowerCase(), v);
154
+ });
155
+ this._setReadyState(HEADERS_RECEIVED);
156
+ this._setReadyState(LOADING);
157
+ switch (this.responseType) {
158
+ case 'arraybuffer': {
159
+ const ab = await res.arrayBuffer();
160
+ this.response = ab;
161
+ this.responseText = '';
162
+ break;
163
+ }
164
+ case 'blob': {
165
+ // Materialise the body to a GLib temp file so URL.createObjectURL
166
+ // can hand Image / Audio / Font consumers a loadable `file://` URL.
167
+ const ab = await res.arrayBuffer();
168
+ const bytes = new Uint8Array(ab);
169
+ const tmpPath = writeBlobToTempFile(bytes, this._url);
170
+ const blob = new Blob([ab], {
171
+ type: this._responseHeaders.get('content-type') ?? '',
172
+ });
173
+ blob._tmpPath = tmpPath;
174
+ this.response = blob;
175
+ this.responseText = '';
176
+ break;
177
+ }
178
+ case 'json': {
179
+ const text = await res.text();
180
+ this.responseText = '';
181
+ try {
182
+ this.response = text.length > 0 ? JSON.parse(text) : null;
183
+ }
184
+ catch {
185
+ this.response = null;
186
+ }
187
+ break;
188
+ }
189
+ case 'document': {
190
+ const text = await res.text();
191
+ this.responseText = text;
192
+ this.response = text;
193
+ break;
194
+ }
195
+ case '':
196
+ case 'text':
197
+ default: {
198
+ const text = await res.text();
199
+ // Strip UTF-8 BOM (U+FEFF) — browsers do this automatically; required
200
+ // for JSON.parse to succeed on BOM-prefixed JSON responses.
201
+ const stripped = text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
202
+ this.responseText = stripped;
203
+ this.response = stripped;
204
+ break;
205
+ }
206
+ }
207
+ this._setReadyState(DONE);
208
+ this.dispatchEvent(new ProgressEvent('load'));
209
+ this.dispatchEvent(new ProgressEvent('loadend'));
210
+ if (this.onload)
211
+ this.onload(new ProgressEvent('load'));
212
+ if (this.onloadend)
213
+ this.onloadend(new ProgressEvent('loadend'));
214
+ })
215
+ .catch((_err) => {
216
+ if (this._timeoutId) {
217
+ clearTimeout(this._timeoutId);
218
+ this._timeoutId = null;
219
+ }
220
+ if (this._aborted)
221
+ return;
222
+ this._setReadyState(DONE);
223
+ const ev = new ProgressEvent('error');
224
+ this.dispatchEvent(ev);
225
+ if (this.onerror)
226
+ this.onerror(ev);
227
+ if (this.onloadend)
228
+ this.onloadend(new ProgressEvent('loadend'));
229
+ });
230
+ }
231
+ abort() {
232
+ if (this._aborted)
233
+ return;
234
+ this._aborted = true;
235
+ if (this._timeoutId) {
236
+ clearTimeout(this._timeoutId);
237
+ this._timeoutId = null;
238
+ }
239
+ this._controller.abort();
240
+ if (this.readyState !== UNSENT && this.readyState !== DONE) {
241
+ this._setReadyState(DONE);
242
+ this.status = 0;
243
+ }
244
+ const ev = new ProgressEvent('abort');
245
+ this.dispatchEvent(ev);
246
+ if (this.onabort)
247
+ this.onabort(ev);
248
+ if (this.onloadend)
249
+ this.onloadend(new ProgressEvent('loadend'));
250
+ }
251
+ overrideMimeType(_mime) { }
252
+ _onTimeout() {
253
+ if (this._aborted)
254
+ return;
255
+ this._aborted = true;
256
+ this._setReadyState(DONE);
257
+ const ev = new ProgressEvent('timeout');
258
+ this.dispatchEvent(ev);
259
+ if (this.ontimeout)
260
+ this.ontimeout(ev);
261
+ }
262
+ _setReadyState(state) {
263
+ this.readyState = state;
264
+ const ev = new Event('readystatechange');
265
+ this.dispatchEvent(ev);
266
+ if (this.onreadystatechange)
267
+ this.onreadystatechange(ev);
268
+ }
269
+ }
270
+ export class XMLHttpRequestUpload extends EventTarget {
271
+ onprogress = null;
272
+ onloadstart = null;
273
+ onloadend = null;
274
+ onload = null;
275
+ onerror = null;
276
+ onabort = null;
277
+ ontimeout = null;
278
+ }
package/package.json CHANGED
@@ -1,28 +1,29 @@
1
1
  {
2
2
  "name": "@gjsify/fetch",
3
- "version": "0.1.15",
3
+ "version": "0.3.0",
4
4
  "description": "Web and Node.js fetch module for Gjs",
5
5
  "module": "lib/esm/index.js",
6
- "types": "lib/types/index.d.ts",
6
+ "types": "lib/index.d.ts",
7
7
  "type": "module",
8
8
  "exports": {
9
9
  ".": {
10
- "types": "./lib/types/index.d.ts",
10
+ "types": "./lib/index.d.ts",
11
11
  "default": "./lib/esm/index.js"
12
12
  },
13
13
  "./register": {
14
- "types": "./lib/types/register.d.ts",
14
+ "types": "./lib/register.d.ts",
15
15
  "default": "./lib/esm/register.js"
16
16
  },
17
17
  "./register/fetch": {
18
18
  "default": "./lib/esm/register/fetch.js"
19
19
  },
20
- "./globals": "./globals.mjs"
20
+ "./register/xhr": {
21
+ "default": "./lib/esm/register/xhr.js"
22
+ }
21
23
  },
22
24
  "sideEffects": [
23
25
  "./lib/esm/register.js",
24
- "./lib/esm/register/*.js",
25
- "./globals.mjs"
26
+ "./lib/esm/register/*.js"
26
27
  ],
27
28
  "scripts": {
28
29
  "clear": "rm -rf lib tsconfig.tsbuildinfo tsconfig.types.tsbuildinfo test.gjs.mjs test.node.mjs || exit 0",
@@ -32,9 +33,10 @@
32
33
  "build:types": "tsc",
33
34
  "build:test": "yarn build:test:gjs && yarn build:test:node",
34
35
  "build:test:gjs": "gjsify build src/test.mts --app gjs --outfile test.gjs.mjs",
36
+ "build:test:browser": "gjsify build src/test.browser.mts --app browser --outfile dist/test.browser.mjs",
35
37
  "build:test:node": "gjsify build src/test.mts --app node --outfile test.node.mjs",
36
38
  "test": "yarn build:gjsify && yarn build:test:node && yarn test:node",
37
- "test:gjs": "gjs -m test.gjs.mjs",
39
+ "test:gjs": "gjsify run test.gjs.mjs",
38
40
  "test:node": "node test.node.mjs"
39
41
  },
40
42
  "keywords": [
@@ -43,19 +45,19 @@
43
45
  "fetch"
44
46
  ],
45
47
  "devDependencies": {
46
- "@gjsify/cli": "^0.1.15",
47
- "@gjsify/unit": "^0.1.15",
48
+ "@gjsify/cli": "^0.3.0",
49
+ "@gjsify/unit": "^0.3.0",
48
50
  "@types/node": "^25.6.0",
49
- "typescript": "^6.0.2"
51
+ "typescript": "^6.0.3"
50
52
  },
51
53
  "dependencies": {
52
- "@girs/gio-2.0": "^2.88.0-4.0.0-rc.3",
53
- "@girs/gjs": "^4.0.0-rc.3",
54
- "@girs/glib-2.0": "^2.88.0-4.0.0-rc.3",
55
- "@girs/soup-3.0": "^3.6.6-4.0.0-rc.3",
56
- "@gjsify/formdata": "^0.1.15",
57
- "@gjsify/http": "^0.1.15",
58
- "@gjsify/url": "^0.1.15",
59
- "@gjsify/utils": "^0.1.15"
54
+ "@girs/gio-2.0": "^2.88.0-4.0.0-rc.9",
55
+ "@girs/gjs": "^4.0.0-rc.9",
56
+ "@girs/glib-2.0": "^2.88.0-4.0.0-rc.9",
57
+ "@girs/soup-3.0": "^3.6.6-4.0.0-rc.9",
58
+ "@gjsify/formdata": "^0.3.0",
59
+ "@gjsify/http": "^0.3.0",
60
+ "@gjsify/url": "^0.3.0",
61
+ "@gjsify/utils": "^0.3.0"
60
62
  }
61
63
  }
package/src/body.ts CHANGED
@@ -137,19 +137,31 @@ export default class Body {
137
137
 
138
138
  // If ReadableStream is available, wrap the Readable into one
139
139
  if (typeof ReadableStream !== 'undefined') {
140
+ let closed = false;
140
141
  return new ReadableStream<Uint8Array>({
141
142
  start(controller) {
142
143
  stream.on('data', (chunk: Buffer | Uint8Array) => {
143
- controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
144
+ if (closed) return;
145
+ try {
146
+ controller.enqueue(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
147
+ } catch { /* consumer cancelled — drop */ }
144
148
  });
145
149
  stream.on('end', () => {
146
- controller.close();
150
+ if (closed) return;
151
+ closed = true;
152
+ // Defensive: consumer may have cancelled the stream already,
153
+ // in which case .close() throws TypeError. Don't surface that
154
+ // as an unhandled error in the nextTick queue.
155
+ try { controller.close(); } catch { /* already closed/cancelled */ }
147
156
  });
148
157
  stream.on('error', (err: Error) => {
149
- controller.error(err);
158
+ if (closed) return;
159
+ closed = true;
160
+ try { controller.error(err); } catch { /* already closed/cancelled */ }
150
161
  });
151
162
  },
152
163
  cancel() {
164
+ closed = true;
153
165
  stream.destroy();
154
166
  }
155
167
  });
@@ -162,6 +174,15 @@ export default class Body {
162
174
  return this[INTERNALS].stream;
163
175
  }
164
176
 
177
+ /** Return the raw body buffer without consuming the stream (used by Request._send). */
178
+ get _rawBodyBuffer(): Buffer | null {
179
+ const b = this[INTERNALS].body;
180
+ if (b === null) return null;
181
+ if (Buffer.isBuffer(b)) return b;
182
+ if (b instanceof Uint8Array) return Buffer.from(b.buffer, b.byteOffset, b.byteLength);
183
+ return null;
184
+ }
185
+
165
186
  get bodyUsed() {
166
187
  return this[INTERNALS].disturbed;
167
188
  }
package/src/index.spec.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { describe, it, expect, on } from '@gjsify/unit';
2
2
 
3
- import fetch, { Headers, Request, Response, FormData } from 'fetch';
3
+ // fetch / Headers / Request / Response / FormData are accessed off globalThis:
4
+ // on Node they are native, on GJS they are installed by
5
+ // `@gjsify/fetch/register` (pulled in automatically by `--globals auto`).
6
+ // XMLHttpRequest is GJS-only and is likewise read from globalThis inside an
7
+ // on('Gjs', …) gate further below.
4
8
 
5
9
  export default async () => {
6
10
 
@@ -171,9 +175,14 @@ export default async () => {
171
175
  expect(clone.headers.get('x-test')).toBe('value');
172
176
  });
173
177
 
174
- await it('should create request with null body', async () => {
175
- const r = new Request('https://example.com', { body: null });
176
- expect(r.body).toBeNull();
178
+ // Firefox returns a non-null body for new Request(url, { body: null }) likely
179
+ // an empty ReadableStream contrary to the spec (body: null → r.body === null).
180
+ // Guard to Node.js + GJS where spec-correct behavior is verified.
181
+ await on(['Node.js', 'Gjs'], async () => {
182
+ await it('should create request with null body', async () => {
183
+ const r = new Request('https://example.com', { body: null });
184
+ expect(r.body).toBeNull();
185
+ });
177
186
  });
178
187
  });
179
188
 
@@ -275,4 +284,56 @@ export default async () => {
275
284
  expect(text).toBe('hello');
276
285
  });
277
286
  });
287
+
288
+ await on('Gjs', async () => {
289
+ await describe('XMLHttpRequest responseType', async () => {
290
+ // Regression: Excalibur.js sets responseType='arraybuffer' for audio and
291
+ // 'blob' for images. Before this fix XHR ignored responseType and always
292
+ // returned text, which crashed gst_memory_new_wrapped on audio decode and
293
+ // broke URL.createObjectURL on image load.
294
+ //
295
+ // XMLHttpRequest is accessed off globalThis (not imported). On GJS it's
296
+ // registered by `@gjsify/fetch/register/xhr` (pulled in by --globals
297
+ // auto when the detector sees `new XMLHttpRequest()`); on Node there
298
+ // is no native XMLHttpRequest, so the suite is gated with on('Gjs', …).
299
+ const XHR = (globalThis as any).XMLHttpRequest;
300
+
301
+ const runXhr = (init: (xhr: any) => void) => new Promise<any>((resolve, reject) => {
302
+ const xhr = new XHR();
303
+ xhr.open('GET', 'data:text/plain;base64,aGVsbG8=');
304
+ init(xhr);
305
+ xhr.onload = () => resolve(xhr);
306
+ xhr.onerror = () => reject(new Error('xhr error'));
307
+ xhr.send();
308
+ });
309
+
310
+ await it('responseType="arraybuffer" yields ArrayBuffer', async () => {
311
+ const xhr = await runXhr((x) => { x.responseType = 'arraybuffer'; });
312
+ expect(xhr.response instanceof ArrayBuffer).toBe(true);
313
+ expect((xhr.response as ArrayBuffer).byteLength).toBe(5);
314
+ });
315
+
316
+ await it('responseType="text" yields decoded string', async () => {
317
+ const xhr = await runXhr((x) => { x.responseType = 'text'; });
318
+ expect(xhr.response).toBe('hello');
319
+ expect(xhr.responseText).toBe('hello');
320
+ });
321
+
322
+ await it('default responseType "" yields text', async () => {
323
+ const xhr = await runXhr(() => { /* responseType left at "" */ });
324
+ expect(xhr.response).toBe('hello');
325
+ expect(xhr.responseText).toBe('hello');
326
+ });
327
+
328
+ await it('responseType="blob" attaches _tmpPath for URL.createObjectURL', async () => {
329
+ const xhr = await runXhr((x) => { x.responseType = 'blob'; });
330
+ const blob = xhr.response as Blob & { _tmpPath?: string };
331
+ expect(blob instanceof Blob).toBe(true);
332
+ expect(typeof blob._tmpPath).toBe('string');
333
+ const url = URL.createObjectURL(blob);
334
+ expect(url.startsWith('file://')).toBe(true);
335
+ URL.revokeObjectURL(url);
336
+ });
337
+ });
338
+ });
278
339
  };
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ import { URL } from '@gjsify/url';
28
28
 
29
29
  export { FormData, Headers, Request, Response, FetchError, AbortError, isRedirect };
30
30
  export { Blob, File };
31
+ export { XMLHttpRequest, XMLHttpRequestUpload } from './xhr.js';
31
32
 
32
33
  import type { SystemError } from './types/index.js';
33
34
 
@@ -153,8 +154,10 @@ export default async function fetch(url: RequestInfo | URL | Request, init: Requ
153
154
  }
154
155
  };
155
156
 
156
- // Listen for cancellation
157
- cancellable.connect('cancelled', () => {
157
+ // Listen for cancellation.
158
+ // Gio.Cancellable.connect() is g_cancellable_connect() pass callback + DestroyNotify (or null).
159
+ // NOT a GObject signal: do NOT pass a signal name as the first argument.
160
+ cancellable.connect(() => {
158
161
  readable.destroy(new AbortError('The operation was aborted.'));
159
162
  });
160
163
 
@@ -0,0 +1,20 @@
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
+
13
+ import { XMLHttpRequest, XMLHttpRequestUpload } from '../xhr.js';
14
+
15
+ if (typeof globalThis.XMLHttpRequest === 'undefined') {
16
+ globalThis.XMLHttpRequest = XMLHttpRequest as unknown as typeof globalThis.XMLHttpRequest;
17
+ }
18
+ if (typeof globalThis.XMLHttpRequestUpload === 'undefined') {
19
+ globalThis.XMLHttpRequestUpload = XMLHttpRequestUpload as unknown as typeof globalThis.XMLHttpRequestUpload;
20
+ }
package/src/register.ts CHANGED
@@ -1,4 +1,5 @@
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
 
4
4
  import './register/fetch.js';
5
+ import './register/xhr.js';
package/src/request.ts CHANGED
@@ -277,8 +277,24 @@ export class Request extends Body {
277
277
  throw new Error('Cannot send request: no Soup session (non-HTTP URL?)');
278
278
  }
279
279
 
280
+ // Soup auto-adds ContentDecoder to new sessions, but it decodes the body
281
+ // without removing the Content-Encoding header, causing double-decompression
282
+ // if we also run DecompressionStream below. Remove it so our JS-level
283
+ // decompression in index.ts handles everything correctly.
284
+ try { session.remove_feature_by_type(Soup.ContentDecoder.$gtype); } catch { /* not present */ }
285
+
280
286
  options.headers._appendToSoupMessage(message);
281
287
 
288
+ // Attach the request body to the Soup message (needed for POST/PUT/PATCH).
289
+ // Use _rawBodyBuffer to read the body without consuming the stream (the
290
+ // `body` getter may have already put the internal Readable into flowing mode,
291
+ // draining its buffer before we get here).
292
+ const rawBuf = this._rawBodyBuffer;
293
+ if (rawBuf !== null && rawBuf.byteLength > 0) {
294
+ const contentType = options.headers.get('content-type') || null;
295
+ message.set_request_body_from_bytes(contentType, new GLib.Bytes(rawBuf));
296
+ }
297
+
282
298
  const cancellable = new Gio.Cancellable();
283
299
 
284
300
  this[INTERNALS].inputStream = await soupSendAsync(session, message, GLib.PRIORITY_DEFAULT, cancellable);
@@ -380,9 +396,11 @@ export const getSoupRequestOptions = (request: Request) => {
380
396
  headers.set('User-Agent', 'gjsify-fetch');
381
397
  }
382
398
 
383
- // HTTP-network-or-cache fetch step 2.15
399
+ // HTTP-network-or-cache fetch step 2.15. Brotli ('br') is omitted: SpiderMonkey
400
+ // 128's DecompressionStream supports only 'gzip' and 'deflate', so advertising
401
+ // 'br' would let servers respond with bodies we cannot decode.
384
402
  if (request.compress && !headers.has('Accept-Encoding')) {
385
- headers.set('Accept-Encoding', 'gzip, deflate, br');
403
+ headers.set('Accept-Encoding', 'gzip, deflate');
386
404
  }
387
405
 
388
406
  let { agent } = request;