@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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
if (closed) return;
|
|
92
|
+
closed = true;
|
|
93
|
+
try {
|
|
94
|
+
controller.close();
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
87
97
|
});
|
|
88
98
|
stream.on("error", (err) => {
|
|
89
|
-
|
|
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(
|
|
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
|
+
}
|
package/lib/esm/register.js
CHANGED
package/lib/esm/request.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
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
|
|
311
|
+
headers.set('Accept-Encoding', 'gzip, deflate');
|
|
293
312
|
}
|
|
294
313
|
let { agent } = request;
|
|
295
314
|
if (typeof agent === 'function') {
|