@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/body.d.ts +2 -0
- package/lib/body.js +36 -3
- package/lib/esm/body.js +27 -3
- package/lib/esm/index.js +4 -1
- package/lib/esm/register/xhr.js +7 -0
- package/lib/esm/register.js +1 -0
- package/lib/esm/request.js +10 -1
- package/lib/esm/xhr.js +258 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +5 -2
- package/lib/index.spec.js +59 -4
- package/lib/register/xhr.d.ts +1 -0
- package/lib/register/xhr.js +18 -0
- package/lib/register.d.ts +1 -0
- package/lib/register.js +3 -2
- package/lib/request.js +21 -2
- package/lib/xhr.d.ts +62 -0
- package/lib/xhr.js +278 -0
- package/package.json +21 -19
- package/src/body.ts +24 -3
- package/src/index.spec.ts +65 -4
- package/src/index.ts +5 -2
- package/src/register/xhr.ts +20 -0
- package/src/register.ts +3 -2
- package/src/request.ts +20 -2
- package/src/test.browser.mts +130 -0
- package/src/test.mts +3 -1
- package/src/xhr.ts +270 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/globals.mjs +0 -12
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.
|
|
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/
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
|
-
"types": "./lib/
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
11
|
"default": "./lib/esm/index.js"
|
|
12
12
|
},
|
|
13
13
|
"./register": {
|
|
14
|
-
"types": "./lib/
|
|
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
|
-
"./
|
|
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": "
|
|
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.
|
|
47
|
-
"@gjsify/unit": "^0.
|
|
48
|
+
"@gjsify/cli": "^0.3.0",
|
|
49
|
+
"@gjsify/unit": "^0.3.0",
|
|
48
50
|
"@types/node": "^25.6.0",
|
|
49
|
-
"typescript": "^6.0.
|
|
51
|
+
"typescript": "^6.0.3"
|
|
50
52
|
},
|
|
51
53
|
"dependencies": {
|
|
52
|
-
"@girs/gio-2.0": "^2.88.0-4.0.0-rc.
|
|
53
|
-
"@girs/gjs": "^4.0.0-rc.
|
|
54
|
-
"@girs/glib-2.0": "^2.88.0-4.0.0-rc.
|
|
55
|
-
"@girs/soup-3.0": "^3.6.6-4.0.0-rc.
|
|
56
|
-
"@gjsify/formdata": "^0.
|
|
57
|
-
"@gjsify/http": "^0.
|
|
58
|
-
"@gjsify/url": "^0.
|
|
59
|
-
"@gjsify/utils": "^0.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
403
|
+
headers.set('Accept-Encoding', 'gzip, deflate');
|
|
386
404
|
}
|
|
387
405
|
|
|
388
406
|
let { agent } = request;
|