@gjsify/fetch 0.4.0 → 0.4.3

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/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.
@@ -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
- }
@@ -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
@@ -1,5 +0,0 @@
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
-
4
- import './register/fetch.js';
5
- import './register/xhr.js';