@gjsify/fetch 0.4.0 → 0.4.4
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/package.json +61 -58
- package/src/body.ts +0 -448
- package/src/errors/abort-error.ts +0 -10
- package/src/errors/base.ts +0 -22
- package/src/errors/fetch-error.ts +0 -26
- package/src/headers.ts +0 -232
- package/src/index.spec.ts +0 -339
- package/src/index.ts +0 -316
- package/src/register/fetch.ts +0 -16
- package/src/register/xhr.ts +0 -20
- package/src/register.ts +0 -5
- package/src/request.ts +0 -423
- package/src/response.ts +0 -227
- package/src/test.browser.mts +0 -130
- package/src/test.mts +0 -9
- package/src/types/index.ts +0 -1
- package/src/types/system-error.ts +0 -11
- package/src/utils/blob-from.ts +0 -8
- package/src/utils/data-uri.ts +0 -29
- package/src/utils/get-search.ts +0 -9
- package/src/utils/is-redirect.ts +0 -11
- package/src/utils/is.ts +0 -88
- package/src/utils/multipart-parser.ts +0 -448
- package/src/utils/referrer.ts +0 -350
- package/src/utils/soup-helpers.ts +0 -37
- package/src/xhr.ts +0 -270
- package/tsconfig.json +0 -22
- package/tsconfig.tsbuildinfo +0 -1
package/src/index.ts
DELETED
|
@@ -1,316 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Adapted from node-fetch (https://github.com/node-fetch/node-fetch) and the Fetch API spec (https://fetch.spec.whatwg.org/)
|
|
3
|
-
// Copyright (c) node-fetch contributors. MIT license.
|
|
4
|
-
// Modifications: Rewritten for GJS using libsoup 3.0 (Soup.Session)
|
|
5
|
-
|
|
6
|
-
import type Gio from '@girs/gio-2.0';
|
|
7
|
-
import GLib from '@girs/glib-2.0';
|
|
8
|
-
import Stream from 'node:stream';
|
|
9
|
-
|
|
10
|
-
import { parseDataUri } from './utils/data-uri.js';
|
|
11
|
-
|
|
12
|
-
import { writeToStream, clone } from './body.js';
|
|
13
|
-
import Response from './response.js';
|
|
14
|
-
import Headers from './headers.js';
|
|
15
|
-
import Request, { getSoupRequestOptions } from './request.js';
|
|
16
|
-
import { FetchError } from './errors/fetch-error.js';
|
|
17
|
-
import { AbortError } from './errors/abort-error.js';
|
|
18
|
-
import { isRedirect } from './utils/is-redirect.js';
|
|
19
|
-
import { FormData } from '@gjsify/formdata';
|
|
20
|
-
import { isDomainOrSubdomain, isSameProtocol } from './utils/is.js';
|
|
21
|
-
import { parseReferrerPolicyFromHeader } from './utils/referrer.js';
|
|
22
|
-
import {
|
|
23
|
-
Blob,
|
|
24
|
-
File,
|
|
25
|
-
} from './utils/blob-from.js';
|
|
26
|
-
|
|
27
|
-
import { URL } from '@gjsify/url';
|
|
28
|
-
|
|
29
|
-
export { FormData, Headers, Request, Response, FetchError, AbortError, isRedirect };
|
|
30
|
-
export { Blob, File };
|
|
31
|
-
export { XMLHttpRequest, XMLHttpRequestUpload } from './xhr.js';
|
|
32
|
-
|
|
33
|
-
import type { SystemError } from './types/index.js';
|
|
34
|
-
|
|
35
|
-
const supportedSchemas = new Set(['data:', 'http:', 'https:', 'file:']);
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Rewrite root-relative URLs (e.g. `/res/images/foo.png`) to `file://` relative
|
|
39
|
-
* to the program directory. In a browser these would resolve against the page
|
|
40
|
-
* origin; in GJS there is no origin, so we map them to the running bundle's
|
|
41
|
-
* directory. This lets apps use the same asset paths across browser and GJS.
|
|
42
|
-
*/
|
|
43
|
-
/**
|
|
44
|
-
* Rewrite root-relative URLs (e.g. `/res/images/foo.png`) to `file://` relative
|
|
45
|
-
* to the program directory. This lets GJS apps load bundled assets using the
|
|
46
|
-
* same paths as in the browser. The security implications (arbitrary file
|
|
47
|
-
* reads via fetch) are acceptable for the current use cases — revisit if
|
|
48
|
-
* @gjsify/fetch is ever used to handle untrusted input.
|
|
49
|
-
*/
|
|
50
|
-
function rewriteRootRelativeUrl(input: RequestInfo | URL | Request): RequestInfo | URL | Request {
|
|
51
|
-
if (typeof input !== 'string') return input;
|
|
52
|
-
if (!input.startsWith('/') || input.startsWith('//')) return input;
|
|
53
|
-
const DEBUG = (globalThis as any).__GJSIFY_DEBUG_FETCH === true;
|
|
54
|
-
try {
|
|
55
|
-
// GJS-only: derive program dir from System.programInvocationName.
|
|
56
|
-
const imports = (globalThis as any).imports;
|
|
57
|
-
const programPath = imports?.system?.programPath
|
|
58
|
-
?? imports?.system?.programInvocationName
|
|
59
|
-
?? '';
|
|
60
|
-
if (!programPath) return input;
|
|
61
|
-
const dir = GLib.path_get_dirname(programPath);
|
|
62
|
-
const rewritten = `file://${dir}${input}`;
|
|
63
|
-
if (DEBUG) console.log(`[fetch] rewrite ${input} → ${rewritten}`);
|
|
64
|
-
return rewritten;
|
|
65
|
-
} catch (err) {
|
|
66
|
-
if (DEBUG) console.warn(`[fetch] rewrite FAILED: ${(err as any)?.message ?? err}`);
|
|
67
|
-
return input;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Fetch function
|
|
73
|
-
*
|
|
74
|
-
* @param url Absolute url or Request instance
|
|
75
|
-
* @param init Fetch options
|
|
76
|
-
*/
|
|
77
|
-
export default async function fetch(url: RequestInfo | URL | Request, init: RequestInit = {}): Promise<Response> {
|
|
78
|
-
// Rewrite root-relative URLs before Request constructor parses them
|
|
79
|
-
url = rewriteRootRelativeUrl(url);
|
|
80
|
-
|
|
81
|
-
// Build request object
|
|
82
|
-
const request = new Request(url, init);
|
|
83
|
-
const { parsedURL, options } = getSoupRequestOptions(request);
|
|
84
|
-
if (!supportedSchemas.has(parsedURL.protocol)) {
|
|
85
|
-
throw new TypeError(`@gjsify/fetch cannot load ${url}. URL scheme "${parsedURL.protocol.replace(/:$/, '')}" is not supported.`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Handle data: URIs
|
|
89
|
-
if (parsedURL.protocol === 'data:') {
|
|
90
|
-
const { buffer, typeFull } = parseDataUri(request.url);
|
|
91
|
-
const response = new Response(Buffer.from(buffer), { headers: { 'Content-Type': typeFull } });
|
|
92
|
-
return response;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Handle file:// URIs via GLib direct read (no Soup needed).
|
|
96
|
-
if (parsedURL.protocol === 'file:') {
|
|
97
|
-
const DEBUG = (globalThis as any).__GJSIFY_DEBUG_FETCH === true;
|
|
98
|
-
if (DEBUG) console.log(`[fetch] file:// ${request.url}`);
|
|
99
|
-
try {
|
|
100
|
-
const path = GLib.filename_from_uri(request.url)[0];
|
|
101
|
-
if (DEBUG) console.log(`[fetch] file:// path=${path}`);
|
|
102
|
-
const [ok, contents] = GLib.file_get_contents(path);
|
|
103
|
-
if (DEBUG) console.log(`[fetch] file:// ok=${ok} bytes=${contents?.byteLength ?? '?'}`);
|
|
104
|
-
if (!ok) {
|
|
105
|
-
throw new FetchError(`Failed to read file: ${path}`, 'system');
|
|
106
|
-
}
|
|
107
|
-
const bytes = contents as Uint8Array;
|
|
108
|
-
// Copy to a fresh Uint8Array backed by its own ArrayBuffer so the
|
|
109
|
-
// Response body owns the memory independently of GLib's buffer.
|
|
110
|
-
const body = new Uint8Array(bytes.byteLength);
|
|
111
|
-
body.set(bytes);
|
|
112
|
-
const resp = new Response(body);
|
|
113
|
-
if (DEBUG) console.log(`[fetch] file:// response created`);
|
|
114
|
-
return resp;
|
|
115
|
-
} catch (error: unknown) {
|
|
116
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
117
|
-
if (DEBUG) console.warn(`[fetch] file:// FAIL: ${err.message}`);
|
|
118
|
-
throw new FetchError(`request to ${request.url} failed, reason: ${err.message}`, 'system', err as unknown as SystemError);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const { signal } = request;
|
|
123
|
-
|
|
124
|
-
// Check if already aborted
|
|
125
|
-
if (signal && signal.aborted) {
|
|
126
|
-
throw new AbortError('The operation was aborted.');
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Send HTTP request via Soup
|
|
130
|
-
let readable: Stream.Readable;
|
|
131
|
-
let cancellable: Gio.Cancellable;
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
const sendRes = await request._send(options);
|
|
135
|
-
readable = sendRes.readable;
|
|
136
|
-
cancellable = sendRes.cancellable;
|
|
137
|
-
} catch (error: unknown) {
|
|
138
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
139
|
-
throw new FetchError(`request to ${request.url} failed, reason: ${err.message}`, 'system', err as unknown as SystemError);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Wire up abort signal to cancellable
|
|
143
|
-
const abortHandler = () => {
|
|
144
|
-
cancellable.cancel();
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
if (signal) {
|
|
148
|
-
signal.addEventListener('abort', abortHandler, { once: true });
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const finalize = () => {
|
|
152
|
-
if (signal) {
|
|
153
|
-
signal.removeEventListener('abort', abortHandler);
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
|
-
|
|
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(() => {
|
|
161
|
-
readable.destroy(new AbortError('The operation was aborted.'));
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
// Handle stream errors
|
|
165
|
-
readable.on('error', (error: SystemError) => {
|
|
166
|
-
finalize();
|
|
167
|
-
// Error is consumed by the body when read
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
const message = request._message;
|
|
171
|
-
const headers = Headers._newFromSoupMessage(message);
|
|
172
|
-
const statusCode = message.status_code;
|
|
173
|
-
const statusMessage = message.get_reason_phrase();
|
|
174
|
-
|
|
175
|
-
// HTTP fetch step 5 — handle redirects
|
|
176
|
-
if (isRedirect(statusCode)) {
|
|
177
|
-
const location = headers.get('Location');
|
|
178
|
-
|
|
179
|
-
let locationURL: URL | null = null;
|
|
180
|
-
try {
|
|
181
|
-
locationURL = location === null ? null : new URL(location, request.url);
|
|
182
|
-
} catch {
|
|
183
|
-
if (request.redirect !== 'manual') {
|
|
184
|
-
finalize();
|
|
185
|
-
throw new FetchError(`uri requested responds with an invalid redirect URL: ${location}`, 'invalid-redirect');
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
switch (request.redirect) {
|
|
190
|
-
case 'error':
|
|
191
|
-
finalize();
|
|
192
|
-
throw new FetchError(`uri requested responds with a redirect, redirect mode is set to error: ${request.url}`, 'no-redirect');
|
|
193
|
-
|
|
194
|
-
case 'manual':
|
|
195
|
-
// Nothing to do — return opaque redirect response
|
|
196
|
-
break;
|
|
197
|
-
|
|
198
|
-
case 'follow': {
|
|
199
|
-
if (locationURL === null) {
|
|
200
|
-
break;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
if (request.counter >= request.follow) {
|
|
204
|
-
finalize();
|
|
205
|
-
throw new FetchError(`maximum redirect reached at: ${request.url}`, 'max-redirect');
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const requestOptions: Omit<RequestInit, 'headers'> & {
|
|
209
|
-
headers: Headers;
|
|
210
|
-
follow: number;
|
|
211
|
-
counter: number;
|
|
212
|
-
agent: string | ((url: URL) => string);
|
|
213
|
-
compress: boolean;
|
|
214
|
-
size: number;
|
|
215
|
-
} = {
|
|
216
|
-
headers: new Headers(request.headers),
|
|
217
|
-
follow: request.follow,
|
|
218
|
-
counter: request.counter + 1,
|
|
219
|
-
agent: request.agent,
|
|
220
|
-
compress: request.compress,
|
|
221
|
-
method: request.method,
|
|
222
|
-
body: clone(request) as unknown as BodyInit | null,
|
|
223
|
-
signal: request.signal,
|
|
224
|
-
size: request.size,
|
|
225
|
-
referrer: request.referrer,
|
|
226
|
-
referrerPolicy: request.referrerPolicy
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
// Don't forward sensitive headers to different domains/protocols
|
|
230
|
-
if (!isDomainOrSubdomain(request.url, locationURL) || !isSameProtocol(request.url, locationURL)) {
|
|
231
|
-
for (const name of ['authorization', 'www-authenticate', 'cookie', 'cookie2']) {
|
|
232
|
-
requestOptions.headers.delete(name);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Cannot follow redirect with body being a readable stream
|
|
237
|
-
if (statusCode !== 303 && request.body && init.body instanceof Stream.Readable) {
|
|
238
|
-
finalize();
|
|
239
|
-
throw new FetchError('Cannot follow redirect with body being a readable stream', 'unsupported-redirect');
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// 303 or POST→GET conversion
|
|
243
|
-
if (statusCode === 303 || ((statusCode === 301 || statusCode === 302) && request.method === 'POST')) {
|
|
244
|
-
requestOptions.method = 'GET';
|
|
245
|
-
requestOptions.body = undefined;
|
|
246
|
-
requestOptions.headers.delete('content-length');
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Update referrer policy from response
|
|
250
|
-
const responseReferrerPolicy = parseReferrerPolicyFromHeader(headers);
|
|
251
|
-
if (responseReferrerPolicy) {
|
|
252
|
-
requestOptions.referrerPolicy = responseReferrerPolicy;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
finalize();
|
|
256
|
-
return fetch(new Request(locationURL, requestOptions as unknown as RequestInit));
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
default:
|
|
260
|
-
throw new TypeError(`Redirect option '${request.redirect}' is not a valid value of RequestRedirect`);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Build response
|
|
265
|
-
const responseOptions = {
|
|
266
|
-
url: request.url,
|
|
267
|
-
status: statusCode,
|
|
268
|
-
statusText: statusMessage,
|
|
269
|
-
headers,
|
|
270
|
-
size: request.size,
|
|
271
|
-
counter: request.counter,
|
|
272
|
-
highWaterMark: request.highWaterMark
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
// Handle content encoding (decompression)
|
|
276
|
-
const codings = headers.get('Content-Encoding');
|
|
277
|
-
|
|
278
|
-
// Skip decompression when:
|
|
279
|
-
// 1. compression support is disabled
|
|
280
|
-
// 2. HEAD request
|
|
281
|
-
// 3. no Content-Encoding header
|
|
282
|
-
// 4. no content response (204)
|
|
283
|
-
// 5. content not modified response (304)
|
|
284
|
-
if (!request.compress || request.method === 'HEAD' || codings === null || statusCode === 204 || statusCode === 304) {
|
|
285
|
-
finalize();
|
|
286
|
-
return new Response(readable, responseOptions);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Try to use DecompressionStream Web API (available in modern SpiderMonkey)
|
|
290
|
-
if (typeof DecompressionStream !== 'undefined') {
|
|
291
|
-
let format: CompressionFormat | null = null;
|
|
292
|
-
|
|
293
|
-
if (codings === 'gzip' || codings === 'x-gzip') {
|
|
294
|
-
format = 'gzip';
|
|
295
|
-
} else if (codings === 'deflate' || codings === 'x-deflate') {
|
|
296
|
-
format = 'deflate';
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (format) {
|
|
300
|
-
const webBody = new Response(readable, responseOptions).body;
|
|
301
|
-
if (webBody) {
|
|
302
|
-
const decompressed = webBody.pipeThrough(new DecompressionStream(format) as ReadableWritablePair<Uint8Array, Uint8Array>);
|
|
303
|
-
finalize();
|
|
304
|
-
return new Response(decompressed as unknown as ReadableStream, responseOptions);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Fallback: return the body as-is (no streaming decompression available)
|
|
310
|
-
finalize();
|
|
311
|
-
return new Response(readable, responseOptions);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Note: globals are no longer registered at import time. Use the `/register`
|
|
315
|
-
// subpath (`import '@gjsify/fetch/register'`) if you need
|
|
316
|
-
// globalThis.fetch / Headers / Request / Response to be set on GJS.
|
package/src/register/fetch.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
// Registers: fetch, Headers, Request, Response
|
|
2
|
-
|
|
3
|
-
import fetch, { Headers, Request, Response } from '../index.js';
|
|
4
|
-
|
|
5
|
-
if (typeof globalThis.fetch === 'undefined') {
|
|
6
|
-
(globalThis as any).fetch = fetch;
|
|
7
|
-
}
|
|
8
|
-
if (typeof globalThis.Headers === 'undefined') {
|
|
9
|
-
(globalThis as any).Headers = Headers;
|
|
10
|
-
}
|
|
11
|
-
if (typeof globalThis.Request === 'undefined') {
|
|
12
|
-
(globalThis as any).Request = Request;
|
|
13
|
-
}
|
|
14
|
-
if (typeof globalThis.Response === 'undefined') {
|
|
15
|
-
(globalThis as any).Response = Response;
|
|
16
|
-
}
|
package/src/register/xhr.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
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
DELETED