@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.
@@ -0,0 +1,130 @@
1
+ import { run, describe, it, expect } from '@gjsify/unit';
2
+
3
+ run({
4
+ async FetchTest() {
5
+ await describe('Headers', async () => {
6
+ await it('creates empty headers', async () => {
7
+ const h = new Headers();
8
+ expect(h.get('content-type')).toBeNull();
9
+ });
10
+
11
+ await it('gets/sets headers case-insensitively', async () => {
12
+ const h = new Headers({ 'Content-Type': 'text/plain' });
13
+ expect(h.get('content-type')).toBe('text/plain');
14
+ expect(h.get('CONTENT-TYPE')).toBe('text/plain');
15
+ });
16
+
17
+ await it('append combines values with comma', async () => {
18
+ const h = new Headers();
19
+ h.append('x-custom', 'a');
20
+ h.append('x-custom', 'b');
21
+ expect(h.get('x-custom')).toBe('a, b');
22
+ });
23
+
24
+ await it('delete removes a header', async () => {
25
+ const h = new Headers({ 'x-token': 'abc' });
26
+ h.delete('x-token');
27
+ expect(h.get('x-token')).toBeNull();
28
+ });
29
+
30
+ await it('has() checks existence', async () => {
31
+ const h = new Headers({ 'x-foo': 'bar' });
32
+ expect(h.has('x-foo')).toBe(true);
33
+ expect(h.has('x-missing')).toBe(false);
34
+ });
35
+ });
36
+
37
+ await describe('Request', async () => {
38
+ await it('constructs with URL string', async () => {
39
+ const r = new Request('https://example.com');
40
+ expect(r.url).toBe('https://example.com/');
41
+ expect(r.method).toBe('GET');
42
+ });
43
+
44
+ await it('method is uppercased', async () => {
45
+ const r = new Request('https://example.com', { method: 'post' });
46
+ expect(r.method).toBe('POST');
47
+ });
48
+
49
+ await it('constructs with custom headers', async () => {
50
+ const r = new Request('https://example.com', {
51
+ headers: { 'content-type': 'application/json' },
52
+ method: 'POST',
53
+ body: '{}',
54
+ });
55
+ expect(r.headers.get('content-type')).toBe('application/json');
56
+ });
57
+
58
+ await it('clone() preserves url and method', async () => {
59
+ const r = new Request('https://example.com', { method: 'DELETE' });
60
+ const c = r.clone();
61
+ expect(c.url).toBe(r.url);
62
+ expect(c.method).toBe('DELETE');
63
+ });
64
+
65
+ await it('has signal property', async () => {
66
+ const r = new Request('https://example.com');
67
+ expect(r.signal).toBeDefined();
68
+ expect(r.signal.aborted).toBe(false);
69
+ });
70
+ });
71
+
72
+ await describe('Response', async () => {
73
+ await it('constructs with status', async () => {
74
+ const r = new Response('body', { status: 201 });
75
+ expect(r.status).toBe(201);
76
+ expect(r.ok).toBe(true);
77
+ });
78
+
79
+ await it('ok is false for error status', async () => {
80
+ const r = new Response('', { status: 404 });
81
+ expect(r.ok).toBe(false);
82
+ });
83
+
84
+ await it('reads text body', async () => {
85
+ const r = new Response('hello world');
86
+ expect(await r.text()).toBe('hello world');
87
+ });
88
+
89
+ await it('reads json body', async () => {
90
+ const r = new Response('{"x":42}');
91
+ expect(await r.json()).toStrictEqual({ x: 42 });
92
+ });
93
+
94
+ await it('reads arrayBuffer body', async () => {
95
+ const r = new Response(new Uint8Array([1, 2, 3]));
96
+ const buf = await r.arrayBuffer();
97
+ expect(buf.byteLength).toBe(3);
98
+ expect(new Uint8Array(buf)[0]).toBe(1);
99
+ });
100
+
101
+ await it('bodyUsed prevents double-read', async () => {
102
+ const r = new Response('data');
103
+ expect(r.bodyUsed).toBe(false);
104
+ await r.text();
105
+ expect(r.bodyUsed).toBe(true);
106
+ });
107
+
108
+ await it('Response.error() returns type=error', async () => {
109
+ const r = Response.error();
110
+ expect(r.ok).toBe(false);
111
+ expect(r.status).toBe(0);
112
+ });
113
+ });
114
+
115
+ await describe('fetch', async () => {
116
+ await it('fetches a local static file', async () => {
117
+ const r = await fetch('/tests/browser/harness/index.html');
118
+ expect(r.ok).toBe(true);
119
+ const text = await r.text();
120
+ expect(text).toContain('<!DOCTYPE html>');
121
+ });
122
+
123
+ await it('returns 404 for missing file', async () => {
124
+ const r = await fetch('/tests/browser/__nonexistent__.html');
125
+ expect(r.ok).toBe(false);
126
+ expect(r.status).toBe(404);
127
+ });
128
+ });
129
+ },
130
+ });
package/src/test.mts CHANGED
@@ -1,4 +1,6 @@
1
- import '@gjsify/node-globals/register';
1
+ import '@gjsify/node-globals/register/process';
2
+ import '@gjsify/node-globals/register/buffer';
3
+ import '@gjsify/node-globals/register/url';
2
4
  import 'fetch/register'; // register fetch/Headers/Request/Response globals on GJS (no-op on Node)
3
5
  import { run } from '@gjsify/unit';
4
6
 
package/src/xhr.ts ADDED
@@ -0,0 +1,270 @@
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
+
10
+ import GLib from 'gi://GLib?version=2.0';
11
+ import fetch from './index.js';
12
+
13
+ let _blobCounter = 0;
14
+
15
+ function guessBlobExt(url: string): string {
16
+ const lower = url.toLowerCase().split('?')[0]!;
17
+ const dot = lower.lastIndexOf('.');
18
+ const ext = dot > -1 ? lower.slice(dot) : '';
19
+ // Restrict to a small safe allow-list; unknown → .bin
20
+ switch (ext) {
21
+ case '.png': case '.jpg': case '.jpeg': case '.gif':
22
+ case '.webp': case '.svg': case '.bmp':
23
+ case '.ttf': case '.otf': case '.woff': case '.woff2':
24
+ case '.mp3': case '.wav': case '.ogg': case '.flac': case '.m4a':
25
+ case '.mp4': case '.webm': case '.mkv':
26
+ case '.xml': case '.tmx': case '.json':
27
+ return ext;
28
+ default:
29
+ return '.bin';
30
+ }
31
+ }
32
+
33
+ function writeBlobToTempFile(bytes: Uint8Array, url: string): string {
34
+ const tmpPath = GLib.build_filenamev([
35
+ GLib.get_tmp_dir(),
36
+ `gjsify-blob-${_blobCounter++}${guessBlobExt(url)}`,
37
+ ]);
38
+ GLib.file_set_contents(tmpPath, bytes);
39
+ return tmpPath;
40
+ }
41
+
42
+ export const UNSENT = 0;
43
+ export const OPENED = 1;
44
+ export const HEADERS_RECEIVED = 2;
45
+ export const LOADING = 3;
46
+ export const DONE = 4;
47
+
48
+ type EventHandler = ((event: ProgressEvent) => void) | null;
49
+
50
+ export class XMLHttpRequest extends EventTarget {
51
+ static UNSENT = UNSENT;
52
+ static OPENED = OPENED;
53
+ static HEADERS_RECEIVED = HEADERS_RECEIVED;
54
+ static LOADING = LOADING;
55
+ static DONE = DONE;
56
+
57
+ readonly UNSENT = UNSENT;
58
+ readonly OPENED = OPENED;
59
+ readonly HEADERS_RECEIVED = HEADERS_RECEIVED;
60
+ readonly LOADING = LOADING;
61
+ readonly DONE = DONE;
62
+
63
+ readyState: number = UNSENT;
64
+ status: number = 0;
65
+ statusText: string = '';
66
+ responseType: string = '';
67
+ responseText: string = '';
68
+ response: unknown = null;
69
+ responseURL: string = '';
70
+ withCredentials: boolean = false;
71
+ timeout: number = 0;
72
+ upload: XMLHttpRequestUpload = new XMLHttpRequestUpload();
73
+
74
+ onreadystatechange: ((event: Event) => void) | null = null;
75
+ onload: EventHandler = null;
76
+ onerror: EventHandler = null;
77
+ onabort: EventHandler = null;
78
+ ontimeout: EventHandler = null;
79
+ onloadstart: EventHandler = null;
80
+ onloadend: EventHandler = null;
81
+ onprogress: EventHandler = null;
82
+
83
+ private _method = 'GET';
84
+ private _url = '';
85
+ private _headers = new Map<string, string>();
86
+ private _responseHeaders = new Map<string, string>();
87
+ private _controller = new AbortController();
88
+ private _aborted = false;
89
+ private _timeoutId: ReturnType<typeof setTimeout> | null = null;
90
+
91
+ open(method: string, url: string, _async = true, _user?: string | null, _password?: string | null): void {
92
+ this._method = method.toUpperCase();
93
+ this._url = url;
94
+ this._headers.clear();
95
+ this._responseHeaders.clear();
96
+ this._aborted = false;
97
+ this._controller = new AbortController();
98
+ this._setReadyState(OPENED);
99
+ }
100
+
101
+ setRequestHeader(header: string, value: string): void {
102
+ if (this.readyState < OPENED) throw new DOMException('Must open first', 'InvalidStateError');
103
+ this._headers.set(header.toLowerCase(), value);
104
+ }
105
+
106
+ getResponseHeader(header: string): string | null {
107
+ return this._responseHeaders.get(header.toLowerCase()) ?? null;
108
+ }
109
+
110
+ getAllResponseHeaders(): string {
111
+ const lines: string[] = [];
112
+ this._responseHeaders.forEach((v, k) => lines.push(`${k}: ${v}`));
113
+ return lines.join('\r\n');
114
+ }
115
+
116
+ send(body?: Document | XMLHttpRequestBodyInit | null): void {
117
+ if (this.readyState !== OPENED) throw new DOMException('Must open first', 'InvalidStateError');
118
+ if (this._aborted) return;
119
+
120
+ const headersInit: Record<string, string> = {};
121
+ this._headers.forEach((v, k) => { headersInit[k] = v; });
122
+
123
+ const fetchOptions: RequestInit = {
124
+ method: this._method,
125
+ headers: headersInit,
126
+ credentials: this.withCredentials ? 'include' : 'omit',
127
+ signal: this._controller.signal,
128
+ };
129
+
130
+ if (body != null && this._method !== 'GET' && this._method !== 'HEAD') {
131
+ fetchOptions.body = body as BodyInit;
132
+ }
133
+
134
+ if (this.timeout > 0) {
135
+ this._timeoutId = setTimeout(() => {
136
+ this._controller.abort();
137
+ this._onTimeout();
138
+ }, this.timeout);
139
+ }
140
+
141
+ this.dispatchEvent(new Event('loadstart'));
142
+ if (this.onloadstart) this.onloadstart(new ProgressEvent('loadstart'));
143
+
144
+ fetch(this._url, fetchOptions)
145
+ .then(async (res) => {
146
+ if (this._aborted) return;
147
+ if (this._timeoutId) { clearTimeout(this._timeoutId); this._timeoutId = null; }
148
+
149
+ this.status = res.status;
150
+ this.statusText = res.statusText;
151
+ this.responseURL = res.url;
152
+
153
+ res.headers.forEach((v: string, k: string) => {
154
+ this._responseHeaders.set(k.toLowerCase(), v);
155
+ });
156
+
157
+ this._setReadyState(HEADERS_RECEIVED);
158
+ this._setReadyState(LOADING);
159
+
160
+ switch (this.responseType) {
161
+ case 'arraybuffer': {
162
+ const ab = await res.arrayBuffer();
163
+ this.response = ab;
164
+ this.responseText = '';
165
+ break;
166
+ }
167
+ case 'blob': {
168
+ // Materialise the body to a GLib temp file so URL.createObjectURL
169
+ // can hand Image / Audio / Font consumers a loadable `file://` URL.
170
+ const ab = await res.arrayBuffer();
171
+ const bytes = new Uint8Array(ab);
172
+ const tmpPath = writeBlobToTempFile(bytes, this._url);
173
+ const blob = new Blob([ab], {
174
+ type: this._responseHeaders.get('content-type') ?? '',
175
+ });
176
+ (blob as unknown as { _tmpPath: string })._tmpPath = tmpPath;
177
+ this.response = blob;
178
+ this.responseText = '';
179
+ break;
180
+ }
181
+ case 'json': {
182
+ const text = await res.text();
183
+ this.responseText = '';
184
+ try {
185
+ this.response = text.length > 0 ? JSON.parse(text) : null;
186
+ } catch {
187
+ this.response = null;
188
+ }
189
+ break;
190
+ }
191
+ case 'document': {
192
+ const text = await res.text();
193
+ this.responseText = text;
194
+ this.response = text;
195
+ break;
196
+ }
197
+ case '':
198
+ case 'text':
199
+ default: {
200
+ const text = await res.text();
201
+ // Strip UTF-8 BOM (U+FEFF) — browsers do this automatically; required
202
+ // for JSON.parse to succeed on BOM-prefixed JSON responses.
203
+ const stripped = text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
204
+ this.responseText = stripped;
205
+ this.response = stripped;
206
+ break;
207
+ }
208
+ }
209
+
210
+ this._setReadyState(DONE);
211
+ this.dispatchEvent(new ProgressEvent('load'));
212
+ this.dispatchEvent(new ProgressEvent('loadend'));
213
+ if (this.onload) this.onload(new ProgressEvent('load'));
214
+ if (this.onloadend) this.onloadend(new ProgressEvent('loadend'));
215
+ })
216
+ .catch((_err: Error) => {
217
+ if (this._timeoutId) { clearTimeout(this._timeoutId); this._timeoutId = null; }
218
+ if (this._aborted) return;
219
+
220
+ this._setReadyState(DONE);
221
+ const ev = new ProgressEvent('error');
222
+ this.dispatchEvent(ev);
223
+ if (this.onerror) this.onerror(ev);
224
+ if (this.onloadend) this.onloadend(new ProgressEvent('loadend'));
225
+ });
226
+ }
227
+
228
+ abort(): void {
229
+ if (this._aborted) return;
230
+ this._aborted = true;
231
+ if (this._timeoutId) { clearTimeout(this._timeoutId); this._timeoutId = null; }
232
+ this._controller.abort();
233
+ if (this.readyState !== UNSENT && this.readyState !== DONE) {
234
+ this._setReadyState(DONE);
235
+ this.status = 0;
236
+ }
237
+ const ev = new ProgressEvent('abort');
238
+ this.dispatchEvent(ev);
239
+ if (this.onabort) this.onabort(ev);
240
+ if (this.onloadend) this.onloadend(new ProgressEvent('loadend'));
241
+ }
242
+
243
+ overrideMimeType(_mime: string): void { /* no-op */ }
244
+
245
+ private _onTimeout(): void {
246
+ if (this._aborted) return;
247
+ this._aborted = true;
248
+ this._setReadyState(DONE);
249
+ const ev = new ProgressEvent('timeout');
250
+ this.dispatchEvent(ev);
251
+ if (this.ontimeout) this.ontimeout(ev);
252
+ }
253
+
254
+ private _setReadyState(state: number): void {
255
+ this.readyState = state;
256
+ const ev = new Event('readystatechange');
257
+ this.dispatchEvent(ev);
258
+ if (this.onreadystatechange) this.onreadystatechange(ev);
259
+ }
260
+ }
261
+
262
+ export class XMLHttpRequestUpload extends EventTarget {
263
+ onprogress: EventHandler = null;
264
+ onloadstart: EventHandler = null;
265
+ onloadend: EventHandler = null;
266
+ onload: EventHandler = null;
267
+ onerror: EventHandler = null;
268
+ onabort: EventHandler = null;
269
+ ontimeout: EventHandler = null;
270
+ }