-
Notifications
You must be signed in to change notification settings - Fork 0
/
index.js
467 lines (438 loc) · 18.6 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
/*! GM_fetch — v0.3.6-2022.06.04-dev — https://github.com/AlttiRi/gm_fetch */
function getGM_fetch() {
const GM_XHR = (typeof GM_xmlhttpRequest === "function") ? GM_xmlhttpRequest : (GM?.xmlHttpRequest);
const isStreamSupported = GM_XHR?.RESPONSE_TYPE_STREAM;
let firefoxFixedFetch = false;
const fetch = getWebPageFetch();
const crError = new Error().stack.startsWith("Error"); // Chromium Error
// In Chromium original `DOMException` contains stack trace, however, manually created does not have it.
/**
* @param {string, URL, Request} resource
* @param fetchInit */
async function handleBaseParams(resource, fetchInit = {}) {
let url;
if (resource?.url) {
const {url: u, init} = await destroyRequest(resource);
url = u;
fetchInit = {...init, ...fetchInit};
} else {
url = new URL(resource, location).href;
}
return {url, fetchInit};
}
/** @param {Request} request */
async function destroyRequest(request) {
const url = request.url;
const method = request.method;
const headers = request.headers;
const signal = request.signal;
const referrer = request.referrer !== "referrer" ? request.referrer : undefined; // todo test
let body;
if (!["GET", "HEAD"].includes(method)) {
body = await request.blob();
}
return {url, init: {method, signal, headers, body}};
}
function getWebPageFetch() {
let fetch = globalThis.fetch;
// [VM/GM/FM + Firefox with "Enhanced Tracking Protection" set to "Strict" (Or "Custom" with enabled "Fingerprinters" option)
// on sites with CSP (like Twitter, GitHub)] requires this fix.
// They run the code as a content script. TM disables CSP with extra HTTP headers.
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Sharing_objects_with_page_scripts
function fixFirefoxFetchOnPageWithCSP() {
const wrappedJSObject = globalThis.wrappedJSObject;
const fixRequired = wrappedJSObject && typeof wrappedJSObject.fetch === "function";
if (!fixRequired) {
return;
}
const isTM = (function() {
const request = new wrappedJSObject.Request(""); // Firefox content script's `Request` does not support relative URLs
try {
return request === cloneInto(request);
} catch {
console.log("[ujs][fixFirefoxFetchOnPageWithCSP] Request:", Request);
return false;
}
})();
if (isTM) {
return;
}
async function fixedFetch(resource, opts = {}) {
const {url, fetchInit: init} = await handleBaseParams(resource, opts);
if (init.headers instanceof Headers) {
console.log("[ujs][fixedFetch] Headers", init.headers);
// Since `Headers` are not allowed for structured cloning.
init.headers = Object.fromEntries(init.headers.entries());
}
if (/** @type {AbortSignal} */ init.signal) {
if (init.signal.aborted) {
throw new DOMException("The user aborted a request." + (crError ? new Error().stack.slice(5) : ""), "AbortError");
}
console.warn("[ujs][fixedFetch] delete signal");
delete init.signal; // Can't be structured cloned
}
return wrappedJSObject.fetch(cloneInto(url, document), cloneInto(init, document/*, {cloneFunctions: true}*/));
}
fetch = fixedFetch;
firefoxFixedFetch = true;
}
fixFirefoxFetchOnPageWithCSP();
console.log({firefoxFixedFetch});
async function enhancedFetch(resource, opts) {
const onprogress = opts.extra?.onprogress;
delete opts.extra;
const response = await fetch(resource, opts);
if (onprogress) {
return responseProgressProxy(response, onprogress);
}
return response;
}
return enhancedFetch;
}
/** The default Response always has {type: "default", redirected: false, url: ""} */
class ResponseEx extends Response {
[Symbol.toStringTag] = "ResponseEx";
constructor(body, {headers, status, statusText, url, redirected, type, ok}) {
super(body, {status, statusText, headers: {
...headers,
"content-type": headers.get("content-type")?.split("; ")[0] // Fixes Blob type ("text/html; charset=UTF-8") in TM
}});
this._type = type;
this._url = url;
this._redirected = redirected;
this._ok = ok;
this._headers = headers; // `HeadersLike` is more user-friendly for debug than the original `Headers` object
}
get redirected() { return this._redirected; }
get url() { return this._url; }
get type() { return this._type || "basic"; } // todo: if "cors"
get ok() { return this._ok; }
/** @returns {HeadersLike} */
get headers() { return this._headers; }
}
class HeadersLike { // Note: the original `Headers` throws an error if `key` requires `.trim()`
constructor(headers) {
headers && Object.entries(headers).forEach(([key, value]) => {
this.append(key, value);
});
}
get(key) {
const value = this[key.trim().toLowerCase()];
return value === undefined ? null : value;
}
append(key, value) {
this[key.trim().toLowerCase()] = value.trim();
}
has(key) {
return this.get(key) !== null;
}
}
/**
* Parses headers from `XMLHttpRequest.getAllResponseHeaders()` string
* @returns {HeadersLike} */
function parseHeaders(headersString) {
const headers = new HeadersLike();
for (const line of headersString.trim().split("\n")) {
const [key, ...valueParts] = line.split(":"); // last-modified: Fri, 21 May 2021 14:46:56 GMT
const value = valueParts.join(":");
headers.append(key, value);
}
return headers;
}
class ReaderLike {
constructor(blobPromise, body) {
/** @type {Promise<Blob>} */
this._blobPromise = blobPromise;
/** @type {ReadableStreamDefaultReader} */
this._reader = null;
/** @type {ReadableStreamLike} */
this._body = body;
this._released = false;
}
/** @return {Promise<{value: Uint8Array, done: boolean}>} */
read() {
if (this._released) {
throw new TypeError("This readable stream reader has been released and cannot be used to read from its previous owner stream");
}
this._body._used = true;
if (this._reader === null) {
return new Promise(async (resolve) => {
const blob = await this._blobPromise;
const response = new Response(blob);
this._reader = response.body.getReader();
const result = await this._reader.read();
resolve(result);
});
}
return this._reader.read();
}
releaseLock() {
this._body.locked = false;
this._released = true;
}
}
class ReadableStreamLike { // BodyLike
constructor(blobPromise) {
this.locked = false;
this._used = false;
this._blobPromise = blobPromise;
}
getReader() {
if (this.locked) {
throw new TypeError("ReadableStreamReader constructor can only accept readable streams that are not yet locked to a reader");
}
this._reader = new ReaderLike(this._blobPromise, this);
this.locked = true;
return this._reader;
}
}
class ResponseLike {
constructor(blobPromise, {headers, status, statusText, url, finalUrl}) {
/** @type {Promise<Blob>} */
this._blobPromise = blobPromise;
this.headers = headers;
this.status = status;
this.statusText = statusText;
this.url = finalUrl;
this.redirected = url !== finalUrl;
this.type = "basic"; // todo: if "cors"
this.ok = status.toString().startsWith("2");
this._bodyUsed = false;
this.body = new ReadableStreamLike(blobPromise);
}
get bodyUsed() {
return this._bodyUsed || this.body._used;
}
blob() {
if (this.bodyUsed) {
throw new TypeError("body stream already read");
}
if (this.body.locked) {
throw new TypeError("body stream is locked");
}
this._bodyUsed = true;
this.body.locked = true;
return this._blobPromise;
}
arrayBuffer() { return this.blob().then(blob => blob.arrayBuffer()); }
text() { return this.blob().then(blob => blob.text()); }
json() { return this.text().then(text => JSON.parse(text)); }
}
const identityContentEncodings = new Set([null, "identity", "no encoding"]);
function getOnProgressProps(response) {
const {headers, status, statusText, url, redirected, ok} = response;
const isIdentity = identityContentEncodings.has(headers.get("Content-Encoding"));
const compressed = !isIdentity;
const _contentLength = parseInt(headers.get("Content-Length")); // `get()` returns `null` if no header present
const contentLength = isNaN(_contentLength) ? null : _contentLength;
const lengthComputable = isIdentity && _contentLength !== null;
// Original XHR behaviour; in TM it equals to `contentLength`, or `-1` if `contentLength` is `null` (and `0`?).
const total = lengthComputable ? contentLength : 0;
const gmTotal = contentLength > 0 ? contentLength : -1; // Like `total` is in TM and GM.
return {
gmTotal, total, lengthComputable,
compressed, contentLength,
headers, status, statusText, url, redirected, ok
};
}
function responseProgressProxy(response, onProgress) {
const onProgressProps = getOnProgressProps(response);
let loaded = 0;
const reader = response.body.getReader();
const readableStream = new ReadableStream({
async start(controller) {
while (true) {
const {done, /** @type {Uint8Array} */ value} = await reader.read();
if (done) {
break;
}
loaded += value.length;
try {
onProgress({loaded, ...onProgressProps});
} catch (e) {
console.error("[onProgress]:", e);
}
controller.enqueue(value);
}
controller.close();
reader.releaseLock();
},
cancel() {
void reader.cancel();
}
});
return new ResponseEx(readableStream, response);
}
/**
* The simplified `fetch` — a wrapper for `GM_xmlHttpRequest`.
* @example
// @grant GM_xmlhttpRequest
const response = await fetch(url);
const {status, statusText} = response;
const lastModified = response.headers.get("last-modified");
const blob = await response.blob();
* @return {Promise<Response>} */
async function GM_fetch(url, fetchInit = {}) {
({url, fetchInit} = await handleBaseParams(url, fetchInit));
if (fetchInit.extra?.webContext) {
delete fetchInit.extra;
return fetch(url, fetchInit);
}
function handleParams(fetchInit) {
const defaultFetchInit = {method: "GET", headers: {}};
const defaultExtra = {useStream: true, onprogress: null};
const opts = {
...defaultFetchInit,
...fetchInit,
extra: {
...defaultExtra,
...fetchInit.extra
}
};
const {headers, method, body, referrer, signal, extra: {useStream, onprogress}} = opts;
delete opts.extra.useStream;
delete opts.extra.onprogress;
const _headers = new HeadersLike(headers);
if (referrer && !_headers.has("referer")) {
_headers.append("referer", referrer); // todo: handle referrer
}
return {
method, headers: _headers, body, signal,
useStream, onprogress, extra: opts.extra
};
}
const {
method, headers, body, signal,
useStream, onprogress, extra
} = handleParams(fetchInit);
if (signal?.aborted) {
throw new DOMException("The user aborted a request." + (crError ? new Error().stack.slice(5) : ""), "AbortError");
}
let abortCallback;
let done = false;
function handleAbort(gmAbort) {
if (!signal) {
return;
}
if (signal.aborted) {
gmAbort();
const id = setInterval(() => done ? clearInterval(id) : gmAbort(), 1); // VM fix.
return;
}
abortCallback = () => gmAbort();
signal.addEventListener("abort", abortCallback);
}
function onDone() {
signal?.removeEventListener("abort", abortCallback);
done = true;
}
const HEADERS_RECEIVED = 2;
const DONE = 4;
function getOnReadyStateChange({onHeadersReceived}) {
return function onReadyStatechange(gmResponse) {
const {readyState} = gmResponse;
if (readyState === HEADERS_RECEIVED) {
onHeadersReceived(gmResponse);
}
// It does not trigger on `abort` and `error`, while native XHR does. (In both TM and VM)
// Fires only on `onload`. Is a bug? // Also it fires (`readyState === DONE`) multiple times in non the latest VM beta.
// else if (readyState === DONE) {
// onDone();
// }
}
}
function getOnDones({resolve, reject}) {
return {
onload(gmResponse) {
onDone();
resolve?.(gmResponse.response); // Not required for `responseType: "stream"`
},
onerror() {
onDone();
reject(new TypeError("Failed to fetch"));
},
onabort() {
onDone();
reject(new DOMException("The user aborted a request." + (crError ? new Error().stack.slice(5) : ""), "AbortError"));
}
};
}
function nonStreamFetch() {
const _onprogress = onprogress;
let onProgressProps = {}; // Will be inited on HEADERS_RECEIVED. It used to have the same behaviour in TM and VM.
return new Promise((resolve, _reject) => {
function onHeadersReceived(gmResponse) {
const {responseHeaders, status, statusText, finalUrl} = gmResponse;
const headers = parseHeaders(responseHeaders);
const response = new ResponseLike(blobPromise, {
headers, status, statusText, url, finalUrl
});
onProgressProps = getOnProgressProps(response);
resolve(response);
}
const onreadystatechange = getOnReadyStateChange({onHeadersReceived});
const blobPromise = new Promise((resolve, reject) => {
const {onload, onabort, onerror} = getOnDones({resolve, reject});
const {abort} = GM_XHR({
...extra,
url,
method,
headers,
responseType: "blob",
onreadystatechange,
onprogress: _onprogress ? ({loaded/*, total, lengthComputable*/}) => {
_onprogress({loaded, ...onProgressProps});
} : undefined,
onload,
onerror,
onabort,
data: body,
});
handleAbort(abort);
});
blobPromise.catch(_reject);
});
}
function streamFetch() {
return new Promise((resolve, reject) => {
function onHeadersReceived(gmResponse) {
const {
responseHeaders, status, statusText, finalUrl, response: readableStream
} = gmResponse;
const headers = parseHeaders(responseHeaders);
const redirected = url !== finalUrl;
let response = new ResponseEx(readableStream, {headers, status, statusText, url: finalUrl, redirected});
if (onprogress) {
response = responseProgressProxy(response, onprogress);
}
resolve(response);
}
const onreadystatechange = getOnReadyStateChange({onHeadersReceived});
const {onload, onabort, onerror} = getOnDones({reject});
const {abort} = GM_XHR({
...extra,
url,
method,
headers,
responseType: "stream",
/* fetch: true, */ // Not required, since it already has `responseType: "stream"`.
onreadystatechange,
onload,
onerror,
onabort,
data: body,
});
handleAbort(abort);
});
}
if (!isStreamSupported || !useStream) {
return nonStreamFetch();
} else {
return streamFetch();
}
}
GM_fetch.isStreamSupported = isStreamSupported;
GM_fetch.webContextFetch = fetch;
GM_fetch.firefoxFixedFetch = firefoxFixedFetch;
return GM_fetch;
}