feat: HTTP add TLS fingerprint fallback (#1444)

This commit is contained in:
Levi authored and GitHub committed 2026-08-20 20:21:02 +08:00
1 parent 99924296ef
commit cfdf87c3b8
68 files changed
+7243 -1604

No files matched your search

+898
View File
@@ -0,0 +1,898 @@
(function () {
const fetchOpen = globalThis.__gopeed_fetch_open;
const fetchRead = globalThis.__gopeed_fetch_read;
const fetchClose = globalThis.__gopeed_fetch_close;
const fetchAbort = globalThis.__gopeed_fetch_abort;
class FormData {
constructor(form) {
if (form !== undefined) throw new TypeError("FormData HTML form construction is not available in an extension worker");
this._entries = [];
}
_normalizeValue(value) {
if (typeof Blob !== "undefined" && value instanceof Blob) return value;
if (value && typeof value === "object" && "name" in value && "size" in value) return value;
return String(value);
}
append(name, value) {
this._entries.push([String(name), this._normalizeValue(value)]);
}
delete(name) {
name = String(name);
this._entries = this._entries.filter((entry) => entry[0] !== name);
}
get(name) {
name = String(name);
const entry = this._entries.find((item) => item[0] === name);
return entry ? entry[1] : null;
}
getAll(name) {
name = String(name);
return this._entries.filter((entry) => entry[0] === name).map((entry) => entry[1]);
}
has(name) {
name = String(name);
return this._entries.some((entry) => entry[0] === name);
}
set(name, value) {
name = String(name);
value = this._normalizeValue(value);
const first = this._entries.findIndex((entry) => entry[0] === name);
if (first < 0) {
this._entries.push([name, value]);
return;
}
this._entries[first] = [name, value];
this._entries = this._entries.filter((entry, index) => index === first || entry[0] !== name);
}
_iterator(kind) {
const data = this;
let index = 0;
return {
next() {
if (index >= data._entries.length) return { value: undefined, done: true };
const entry = data._entries[index++];
if (kind === "key") return { value: entry[0], done: false };
if (kind === "value") return { value: entry[1], done: false };
return { value: [entry[0], entry[1]], done: false };
},
[Symbol.iterator]() { return this; },
};
}
entries() { return this._iterator("entry"); }
keys() { return this._iterator("key"); }
values() { return this._iterator("value"); }
[Symbol.iterator]() { return this.entries(); }
forEach(callback, thisArg) {
for (const [name, value] of this) callback.call(thisArg, value, name, this);
}
}
Object.defineProperty(FormData.prototype, Symbol.toStringTag, { configurable: true, value: "FormData" });
globalThis.FormData = FormData;
const headerNamePattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
const headersIteratorBase = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]()));
const headersIteratorPrototype = Object.create(headersIteratorBase);
function normalizeHeaderName(name) {
if (typeof name === "symbol") throw new TypeError("HTTP header names must be ByteStrings");
name = String(name);
for (let index = 0; index < name.length; index++) {
if (name.charCodeAt(index) > 0xFF) throw new TypeError("HTTP header names must be ByteStrings");
}
if (!headerNamePattern.test(name)) throw new TypeError("Invalid HTTP header name");
return name.toLowerCase();
}
function normalizeHeaderValue(value) {
value = String(value);
for (let index = 0; index < value.length; index++) {
if (value.charCodeAt(index) > 0xFF) throw new TypeError("HTTP header values must be ByteStrings");
}
value = value.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "");
if (/[\0\n\r]/.test(value)) throw new TypeError("Invalid HTTP header value");
return value;
}
function normalizeStatusText(value) {
value = String(value);
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index);
if (code > 0xFF || (code !== 0x09 && (code < 0x20 || code === 0x7F))) {
throw new TypeError("Invalid response status text");
}
}
return value;
}
function sortedHeaderEntries(headers) {
const result = [];
for (const name of Array.from(headers._values.keys()).sort()) {
const values = headers._values.get(name);
if (name === "set-cookie") {
for (const value of values) result.push([name, value]);
} else {
result.push([name, values.join(", ")]);
}
}
return result;
}
Object.defineProperty(headersIteratorPrototype, "next", {
configurable: true,
enumerable: true,
writable: true,
value: function () {
const entries = sortedHeaderEntries(this._headers);
if (this._index >= entries.length) return { value: undefined, done: true };
const entry = entries[this._index++];
if (this._kind === "key") return { value: entry[0], done: false };
if (this._kind === "value") return { value: entry[1], done: false };
return { value: entry, done: false };
},
});
Object.defineProperty(headersIteratorPrototype, Symbol.iterator, {
configurable: true,
enumerable: false,
writable: true,
value: function () { return this; },
});
function createHeadersIterator(headers, kind) {
const iterator = Object.create(headersIteratorPrototype);
Object.defineProperties(iterator, {
_headers: { value: headers },
_kind: { value: kind },
_index: { value: 0, writable: true },
});
return iterator;
}
function forbiddenRequestHeader(name, value) {
if (name.startsWith("proxy-") || name.startsWith("sec-")) return true;
if ([
"accept-charset", "accept-encoding", "access-control-request-headers",
"access-control-request-method", "connection", "content-length", "cookie",
"cookie2", "date", "dnt", "expect", "host", "keep-alive", "origin",
"permissions-policy", "referer", "te", "trailer", "transfer-encoding",
"set-cookie", "upgrade", "via",
].includes(name)) return true;
if (["x-http-method", "x-http-method-override", "x-method-override"].includes(name)) {
return String(value).split(",").some((method) => {
method = method.trim().toUpperCase();
return method === "CONNECT" || method === "TRACE" || method === "TRACK";
});
}
return false;
}
function noCORSSafelistedRequestHeader(name, value) {
if (value.length > 128) return false;
const hasUnsafeByte = /[\x00-\x08\x0A-\x1F\x7F"():<>?@\[\\\]{}]/.test(value);
if (name === "accept") return !hasUnsafeByte;
if (name === "accept-language" || name === "content-language") {
return /^[0-9A-Za-z *,\-.;=]*$/.test(value);
}
if (name === "content-type") {
if (hasUnsafeByte) return false;
const essence = value.split(";", 1)[0].trim().toLowerCase();
return essence === "application/x-www-form-urlencoded" ||
essence === "multipart/form-data" || essence === "text/plain";
}
if (name === "range") return /^bytes=[0-9]+-[0-9]*$/.test(value);
return false;
}
function blockedByGuard(headers, name, value) {
if (headers._guard === "immutable") throw new TypeError("Headers are immutable");
if (headers._guard === "request") return forbiddenRequestHeader(name, value);
if (headers._guard === "request-no-cors") {
return forbiddenRequestHeader(name, value) || !noCORSSafelistedRequestHeader(name, value);
}
if (headers._guard === "response") return name === "set-cookie" || name === "set-cookie2";
return false;
}
class Headers {
constructor(init, guard) {
this._values = new Map();
this._guard = guard || "none";
if (init === undefined) return;
if (init === null || (typeof init !== "object" && typeof init !== "function")) {
throw new TypeError("Headers init must be a sequence or record");
}
const iterator = init[Symbol.iterator];
if (typeof iterator === "function") {
for (const pair of init) {
if (pair == null || typeof pair[Symbol.iterator] !== "function") {
throw new TypeError("Header pair must be iterable");
}
const values = Array.from(pair);
if (values.length !== 2) throw new TypeError("Header pair must contain exactly two items");
this.append(values[0], values[1]);
}
return;
}
for (const name of Reflect.ownKeys(init)) {
const descriptor = Reflect.getOwnPropertyDescriptor(init, name);
if (!descriptor || !descriptor.enumerable) continue;
const normalizedName = normalizeHeaderName(name);
this.append(normalizedName, init[name]);
}
}
append(name, value) {
name = normalizeHeaderName(name);
value = normalizeHeaderValue(value);
const values = this._values.get(name);
const combinedValue = values && name !== "set-cookie" ? values.join(", ") + ", " + value : value;
if (blockedByGuard(this, name, combinedValue)) return;
if (values) values.push(value);
else this._values.set(name, [value]);
}
delete(name) {
name = normalizeHeaderName(name);
if (this._guard === "immutable") throw new TypeError("Headers are immutable");
if (this._guard === "request-no-cors") {
if (forbiddenRequestHeader(name, "") || !["accept", "accept-language", "content-language", "content-type", "range"].includes(name)) return;
this._values.delete(name);
return;
}
if (blockedByGuard(this, name, "")) return;
this._values.delete(name);
}
get(name) {
const values = this._values.get(normalizeHeaderName(name));
return values ? values.join(", ") : null;
}
getSetCookie() {
const values = this._values.get("set-cookie");
return values ? values.slice() : [];
}
has(name) {
return this._values.has(normalizeHeaderName(name));
}
set(name, value) {
name = normalizeHeaderName(name);
value = normalizeHeaderValue(value);
if (blockedByGuard(this, name, value)) return;
this._values.set(name, [value]);
}
keys() { return createHeadersIterator(this, "key"); }
values() { return createHeadersIterator(this, "value"); }
entries() { return createHeadersIterator(this, "entry"); }
[Symbol.iterator]() { return this.entries(); }
forEach(callback, thisArg) {
for (const [name, value] of this) callback.call(thisArg, value, name, this);
}
}
globalThis.Headers = Headers;
const forbiddenMethods = new Set(["CONNECT", "TRACE", "TRACK"]);
function normalizeMethod(method) {
method = String(method);
if (!headerNamePattern.test(method)) throw new TypeError("Invalid HTTP method");
method = method.toUpperCase();
if (forbiddenMethods.has(method)) throw new TypeError("Forbidden HTTP method");
return method;
}
function resolveURL(value) {
const base = globalThis.location && globalThis.location.href ? globalThis.location.href : "http://localhost/";
const parsed = new URL(String(value), base);
if (parsed.username || parsed.password) throw new TypeError("Request URLs cannot contain credentials");
return parsed.href;
}
function normalizeEnum(value, allowed, name) {
value = String(value);
if (!allowed.includes(value)) throw new TypeError("Invalid Request " + name);
return value;
}
function normalizeReferrer(value) {
value = String(value);
if (value === "") return "no-referrer";
if (value === "about:client") return value;
return resolveURL(value);
}
function copyArrayBuffer(value) {
if (value instanceof ArrayBuffer) return value.slice(0);
return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
}
function initializeBody(owner, body) {
owner._bodyInit = body == null ? null : body;
owner._bodyText = null;
owner._bodyArrayBuffer = null;
owner._bodyBlob = null;
owner._bodyFormData = null;
owner._bodyFormDataBoundary = "";
owner._bodyStream = null;
owner._bodyUsed = false;
if (body == null) return;
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) {
owner._bodyStream = body;
body.__gopeedMarkBodyUsed = () => { owner._bodyUsed = true; };
} else if (typeof body === "string") {
owner._bodyText = body;
} else if (typeof Blob !== "undefined" && body instanceof Blob) {
owner._bodyBlob = body;
} else if (typeof FormData !== "undefined" && body instanceof FormData) {
owner._bodyFormData = body;
const suffix = crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + String(Math.random()).slice(2);
owner._bodyFormDataBoundary = "----gopeed-" + suffix;
} else if (body instanceof ArrayBuffer || (ArrayBuffer.isView && ArrayBuffer.isView(body))) {
owner._bodyArrayBuffer = copyArrayBuffer(body);
} else if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) {
owner._bodyText = body.toString();
} else {
owner._bodyText = String(body);
}
}
function bodyContentType(owner) {
if (owner._bodyText != null) {
if (typeof URLSearchParams !== "undefined" && owner._bodyInit instanceof URLSearchParams) {
return "application/x-www-form-urlencoded;charset=UTF-8";
}
return "text/plain;charset=UTF-8";
}
if (owner._bodyBlob && owner._bodyBlob.type) return owner._bodyBlob.type;
if (owner._bodyFormData) return "multipart/form-data; boundary=" + owner._bodyFormDataBoundary;
return "";
}
function decodeFormComponent(value) {
return decodeURIComponent(String(value).replace(/\+/g, " "));
}
function installBodyMethods(prototype) {
Object.defineProperties(prototype, {
body: {
configurable: true,
enumerable: true,
get() {
if (this._bodyInit == null && !this.__gopeedBodyStream) return null;
if (this.__gopeedBodyStream) return this.__gopeedBodyStream;
if (!this._bodyStream) this._bodyStream = createBodyReadableStream(this);
return this._bodyStream;
},
},
bodyUsed: {
configurable: true,
enumerable: true,
get() { return Boolean(this._bodyUsed || this.__gopeedBodyConsumed); },
},
});
prototype.text = async function () {
if (this.bodyUsed) throw new TypeError("Already read");
if (this.body == null) return "";
return readAllFromStream(this.body, true);
};
prototype.arrayBuffer = async function () {
if (this.bodyUsed) throw new TypeError("Already read");
if (this.body == null) return new ArrayBuffer(0);
const bytes = await readAllFromStream(this.body, false);
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
};
prototype.bytes = async function () {
if (this.bodyUsed) throw new TypeError("Already read");
if (this.body == null) return new Uint8Array(0);
return readAllFromStream(this.body, false);
};
prototype.blob = async function () {
const data = await this.arrayBuffer();
return new Blob([data], { type: this.headers.get("content-type") || "" });
};
prototype.json = async function () { return JSON.parse(await this.text()); };
prototype.formData = async function () {
const contentType = this.headers.get("content-type") || "";
const essence = contentType.split(";", 1)[0].trim().toLowerCase();
if (essence === "multipart/form-data" && this._bodyFormData) {
if (this.bodyUsed) throw new TypeError("Already read");
await readAllFromStream(this.body, false);
const result = new FormData();
for (const entry of Array.from(this._bodyFormData.entries())) result.append(entry[0], entry[1]);
return result;
}
if (essence !== "application/x-www-form-urlencoded") {
throw new TypeError("Body is not URL-encoded form data");
}
if (this.body == null) return new FormData();
const text = await this.text();
const result = new FormData();
for (const entry of text.split("&")) {
if (!entry) continue;
const parts = entry.split("=");
const name = parts.shift();
result.append(decodeFormComponent(name), decodeFormComponent(parts.join("=")));
}
return result;
};
}
function transferReadableStream(stream) {
let reader = null;
return new ReadableStream({
async pull(controller) {
if (!reader) reader = stream.getReader();
const result = await reader.read();
if (result.done) {
reader.releaseLock();
controller.close();
} else {
controller.enqueue(result.value);
}
},
cancel(reason) {
if (!reader) reader = stream.getReader();
return reader.cancel(reason);
},
});
}
class Request {
constructor(input, init) {
if (arguments.length < 1) throw new TypeError("Request requires an input");
init = init || {};
if (init.window !== undefined && init.window !== null) throw new TypeError("Request window must be null");
const source = input instanceof Request ? input : null;
const bodyWasOverridden = Object.prototype.hasOwnProperty.call(init, "body");
if (source && source.bodyUsed && !bodyWasOverridden) throw new TypeError("Request body is already used");
this._url = source ? source.url : resolveURL(input);
this._method = normalizeMethod(init.method !== undefined ? init.method : (source ? source.method : "GET"));
this._destination = "";
this._referrer = init.referrer !== undefined ? normalizeReferrer(init.referrer) : (source ? source.referrer : "about:client");
this._referrerPolicy = normalizeEnum(init.referrerPolicy !== undefined ? init.referrerPolicy : (source ? source.referrerPolicy : ""), ["", "no-referrer", "no-referrer-when-downgrade", "origin", "origin-when-cross-origin", "same-origin", "strict-origin", "strict-origin-when-cross-origin", "unsafe-url"], "referrerPolicy");
this._mode = normalizeEnum(init.mode !== undefined ? init.mode : (source ? source.mode : "cors"), ["same-origin", "no-cors", "cors"], "mode");
this._credentials = normalizeEnum(init.credentials !== undefined ? init.credentials : (source ? source.credentials : "same-origin"), ["omit", "same-origin", "include"], "credentials");
this._cache = normalizeEnum(init.cache !== undefined ? init.cache : (source ? source.cache : "default"), ["default", "no-store", "reload", "no-cache", "force-cache", "only-if-cached"], "cache");
this._redirect = normalizeEnum(init.redirect !== undefined ? init.redirect : (source ? source.redirect : "follow"), ["follow", "error", "manual"], "redirect");
if (this._mode === "no-cors" && !["GET", "HEAD", "POST"].includes(this._method)) {
throw new TypeError("no-cors requests require a CORS-safelisted method");
}
if (this._cache === "only-if-cached" && this._mode !== "same-origin") {
throw new TypeError("only-if-cached requires same-origin mode");
}
this._integrity = init.integrity !== undefined ? String(init.integrity) : (source ? source.integrity : "");
this._keepalive = init.keepalive !== undefined ? Boolean(init.keepalive) : (source ? source.keepalive : false);
this._priority = normalizeEnum(init.priority !== undefined ? init.priority : (source ? source._priority : "auto"), ["high", "low", "auto"], "priority");
this._signal = init.signal || (source && source.signal) || new AbortController().signal;
if (init.duplex !== undefined && String(init.duplex) !== "half") {
throw new TypeError("Request duplex must be 'half'");
}
this._duplex = "half";
const headerInit = init.headers !== undefined ? init.headers : (source ? source.headers : undefined);
this._headers = new Headers(headerInit, this._mode === "no-cors" ? "request-no-cors" : "request");
let body = init.body !== undefined ? init.body : (source ? source._bodyInit : null);
if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body);
const bodyIsStream = typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
if (bodyIsStream && (body.locked || body._disturbed)) {
throw new TypeError("ReadableStream body is locked or disturbed");
}
if (bodyIsStream && init.body !== undefined && init.duplex === undefined) {
throw new TypeError("ReadableStream request bodies require duplex: 'half'");
}
if (bodyIsStream && this._keepalive) {
throw new TypeError("keepalive requests cannot have a ReadableStream body");
}
if (source && !bodyWasOverridden && source.body && (source.body.locked || source.body._disturbed)) {
throw new TypeError("Request body is locked or disturbed");
}
if ((this._method === "GET" || this._method === "HEAD") && body != null) {
throw new TypeError("Body not allowed for GET or HEAD requests");
}
initializeBody(this, body);
const contentType = bodyContentType(this);
if (contentType && !this._headers.has("content-type")) this._headers.set("content-type", contentType);
if (source && source.body) source._bodyUsed = true;
}
get method() { return this._method; }
get url() { return this._url; }
get headers() { return this._headers; }
get destination() { return this._destination; }
get referrer() { return this._referrer; }
get referrerPolicy() { return this._referrerPolicy; }
get mode() { return this._mode; }
get credentials() { return this._credentials; }
get cache() { return this._cache; }
get redirect() { return this._redirect; }
get integrity() { return this._integrity; }
get keepalive() { return this._keepalive; }
get signal() { return this._signal; }
get isReloadNavigation() { return false; }
get isHistoryNavigation() { return false; }
get duplex() { return this._duplex; }
clone() {
if (this.bodyUsed) throw new TypeError("Request body is already used");
let body = null;
if (this.body) {
const branches = this.body.tee();
this._bodyInit = branches[0];
this._bodyStream = branches[0];
if (this.__gopeedBodyStream) this.__gopeedBodyStream = branches[0];
branches[0].__gopeedMarkBodyUsed = () => { this._bodyUsed = true; };
body = branches[1];
}
return new Request(this.url, {
method: this.method,
headers: this.headers,
body,
mode: this.mode,
credentials: this.credentials,
cache: this.cache,
redirect: this.redirect,
referrer: this.referrer,
referrerPolicy: this.referrerPolicy,
integrity: this.integrity,
keepalive: this.keepalive,
signal: this.signal,
duplex: "half",
});
}
}
installBodyMethods(Request.prototype);
const nullBodyStatuses = new Set([101, 103, 204, 205, 304]);
class Response {
constructor(body, init) {
init = init || {};
const status = init.status === undefined ? 200 : Number(init.status);
if (!Number.isInteger(status) || status < 200 || status > 599) {
throw new RangeError("Invalid response status");
}
if (body != null && nullBodyStatuses.has(status)) {
throw new TypeError("Response with null-body status cannot have a body");
}
const statusText = init.statusText === undefined ? "" : normalizeStatusText(init.statusText);
this._type = "default";
this._url = init.url ? String(init.url) : "";
this._redirected = Boolean(init.redirected);
this._status = status;
this._statusText = statusText;
this._headers = new Headers(init.headers, "response");
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream && (body.locked || body._disturbed)) {
throw new TypeError("ReadableStream body is locked or disturbed");
}
initializeBody(this, body == null ? null : body);
const contentType = bodyContentType(this);
if (contentType && !this._headers.has("content-type")) this._headers.set("content-type", contentType);
}
get type() { return this._type; }
get url() { return this._url; }
get redirected() { return this._redirected; }
get status() { return this._status; }
get ok() { return this._status >= 200 && this._status <= 299; }
get statusText() { return this._statusText; }
get headers() { return this._headers; }
clone() {
if (this.bodyUsed) throw new TypeError("Response body is already used");
let body = null;
if (this.body) {
const branches = this.body.tee();
this._bodyInit = branches[0];
this._bodyStream = branches[0];
if (this.__gopeedBodyStream) this.__gopeedBodyStream = branches[0];
branches[0].__gopeedMarkBodyUsed = () => { this._bodyUsed = true; };
body = branches[1];
}
return new Response(body, {
status: this.status,
statusText: this.statusText,
headers: this.headers,
url: this.url,
redirected: this.redirected,
});
}
static error() {
const response = Object.create(Response.prototype);
response._type = "error";
response._url = "";
response._redirected = false;
response._status = 0;
response._statusText = "";
response._headers = new Headers(undefined, "immutable");
initializeBody(response, null);
return response;
}
static redirect(url, status) {
status = status === undefined ? 302 : Number(status);
if (![301, 302, 303, 307, 308].includes(status)) throw new RangeError("Invalid redirect status");
return new Response(null, { status, headers: { location: resolveURL(url) } });
}
static json(data, init) {
const body = JSON.stringify(data);
if (body === undefined) throw new TypeError("Value is not JSON serializable");
init = Object.assign({}, init || {});
const headers = new Headers(init.headers);
if (!headers.has("content-type")) headers.set("content-type", "application/json");
init.headers = headers;
return new Response(body, init);
}
}
installBodyMethods(Response.prototype);
globalThis.Request = Request;
globalThis.Response = Response;
function toUint8Array(chunk) {
if (chunk == null) return new Uint8Array(0);
if (chunk instanceof Uint8Array) return chunk;
if (typeof chunk === "string") return new TextEncoder().encode(chunk);
if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk);
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView && ArrayBuffer.isView(chunk)) {
return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
}
if (typeof Blob !== "undefined" && chunk instanceof Blob && chunk._buffer instanceof Uint8Array) {
return chunk._buffer;
}
return new Uint8Array(0);
}
function createBodyReadableStream(owner) {
const stream = new ReadableStream({
start(controller) {
Promise.resolve().then(async () => {
if (owner._noBody) {
controller.close();
return;
}
if (owner._bodyArrayBuffer) {
controller.enqueue(toUint8Array(owner._bodyArrayBuffer));
controller.close();
return;
}
if (owner._bodyBlob) {
const data = await owner._bodyBlob.arrayBuffer();
controller.enqueue(new Uint8Array(data));
controller.close();
return;
}
if (owner._bodyFormData) {
const boundary = owner._bodyFormDataBoundary;
const entries = Array.from(owner._bodyFormData.entries());
if (entries.length === 0) {
controller.close();
return;
}
let serialized = "";
for (const entry of entries) {
const name = String(entry[0]).replace(/\r|\n|"/g, (character) =>
character === "\r" ? "%0D" : character === "\n" ? "%0A" : "%22");
serialized += "--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + name + "\"\r\n\r\n";
serialized += String(entry[1]) + "\r\n";
}
serialized += "--" + boundary + "--\r\n";
controller.enqueue(toUint8Array(serialized));
controller.close();
return;
}
if (owner._bodyText != null) {
controller.enqueue(toUint8Array(owner._bodyText));
controller.close();
return;
}
if (owner._bodyInit != null) controller.enqueue(toUint8Array(owner._bodyInit));
controller.close();
}).catch((error) => controller.error(error));
},
});
stream.__gopeedMarkBodyUsed = () => { owner._bodyUsed = true; };
return stream;
}
async function readAllFromStream(stream, asText) {
if (!stream) return asText ? "" : new Uint8Array(0);
const reader = stream.getReader();
const chunks = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!(value instanceof Uint8Array)) {
throw new TypeError("Fetch body stream chunks must be Uint8Array values");
}
const chunk = value;
chunks.push(chunk);
total += chunk.byteLength;
}
} finally {
reader.releaseLock();
}
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
return asText ? new TextDecoder().decode(merged) : merged;
}
function attachResponseStreaming(response, stream) {
response.__gopeedBodyStream = stream;
response.__gopeedBodyConsumed = false;
const ensureUnused = () => {
if (response.__gopeedBodyConsumed) throw new TypeError("Already read");
};
const markBodyUsed = () => {
ensureUnused();
response.__gopeedBodyConsumed = true;
response.bodyUsed = true;
};
if (stream) {
stream.__gopeedMarkBodyUsed = () => {
if (!response.__gopeedBodyConsumed) {
response.__gopeedBodyConsumed = true;
response.bodyUsed = true;
}
};
}
response.text = async function () {
markBodyUsed();
return readAllFromStream(this.body, true);
};
response.arrayBuffer = async function () {
markBodyUsed();
const bytes = await readAllFromStream(this.body, false);
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
};
response.bytes = async function () {
markBodyUsed();
return readAllFromStream(this.body, false);
};
response.blob = async function () {
markBodyUsed();
const contentType = this.headers && this.headers.get ? (this.headers.get("content-type") || "") : "";
const bytes = await readAllFromStream(this.body, false);
return new Blob([bytes], { type: contentType });
};
response.json = async function () {
return JSON.parse(await this.text());
};
return response;
}
if (typeof globalThis.Response === "function") {
const responseProto = globalThis.Response.prototype;
const bodyDescriptor = Object.getOwnPropertyDescriptor(responseProto, "body");
if (!bodyDescriptor || typeof bodyDescriptor.get !== "function") {
Object.defineProperty(responseProto, "body", {
configurable: true,
enumerable: true,
get() {
if (!this.__gopeedBodyStream) this.__gopeedBodyStream = createBodyReadableStream(this);
return this.__gopeedBodyStream;
},
});
}
}
if (typeof fetchOpen !== "function") return;
globalThis.fetch = async function (input, init) {
const request = new Request(input, init);
const requestID = crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + "-" + String(Math.random());
const signal = request.signal;
let abortHandler = null;
const cleanupAbort = () => {
if (signal && abortHandler && typeof signal.removeEventListener === "function") {
signal.removeEventListener("abort", abortHandler);
}
abortHandler = null;
};
if (signal) {
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
abortHandler = () => fetchAbort(requestID, signal.reason == null ? "" : String(signal.reason));
signal.addEventListener("abort", abortHandler, { once: true });
}
let body = null;
if (request._bodyStream && !request._bodyFormData) {
const bytes = await readAllFromStream(request._bodyStream, false);
body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
} else if (request._bodyFormData) {
body = request._bodyFormData._entries;
} else if (request._bodyArrayBuffer) {
body = request._bodyArrayBuffer;
} else if (request._bodyBlob) {
body = await request._bodyBlob.arrayBuffer();
} else if (request._bodyInit != null && typeof request._bodyInit === "object") {
body = request._bodyInit;
} else if (request._bodyText != null) {
body = request._bodyText;
} else if (request._bodyInit != null) {
body = request._bodyInit;
}
const headers = [];
request.headers.forEach((value, key) => headers.push([key, value]));
let metadata;
try {
metadata = await fetchOpen({
id: requestID,
url: request.url,
method: request.method,
headers,
body,
bodyType: request._bodyFormData ? "formdata" : "",
bodyBoundary: request._bodyFormDataBoundary,
redirect: request.redirect,
credentials: request.credentials,
});
} catch (error) {
cleanupAbort();
if (signal && signal.aborted) throw new DOMException("Aborted", "AbortError");
throw error instanceof Error ? error : new TypeError(String(error));
}
const stream = new ReadableStream({
async pull(controller) {
let chunk;
try {
chunk = await fetchRead(metadata.id, 64 * 1024);
} catch (error) {
fetchClose(metadata.id);
cleanupAbort();
controller.error(error instanceof Error ? error : new TypeError(String(error)));
return;
}
if (chunk == null) {
fetchClose(metadata.id);
cleanupAbort();
controller.close();
return;
}
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
if (bytes.byteLength === 0) {
fetchClose(metadata.id);
cleanupAbort();
controller.close();
return;
}
controller.enqueue(bytes);
},
cancel(reason) {
cleanupAbort();
fetchAbort(metadata.id, reason == null ? "" : String(reason));
},
});
const response = new Response(null, {
status: metadata.status,
statusText: metadata.statusText,
headers: metadata.headers,
url: metadata.url,
redirected: metadata.redirected,
});
response._type = "basic";
response._headers._guard = "immutable";
return attachResponseStreaming(response, stream);
};
})();
+619
View File
@@ -0,0 +1,619 @@
package fetch
import (
"bytes"
"context"
_ "embed"
"errors"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync"
"github.com/GopeedLab/gopeed/internal/httpclient"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
"github.com/GopeedLab/gopeed/pkg/download/engine/util"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/eventloop"
"golang.org/x/net/publicsuffix"
)
const FingerprintMagicKey = "__gopeed_fetch_fingerprint"
//go:embed fetch.js
var script string
type Config struct {
ProxyHandler func(*http.Request) (*url.URL, error)
RegisterCleanup func(func())
}
func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error {
if cfg == nil {
cfg = &Config{}
}
registry := newRegistry()
if cfg.RegisterCleanup != nil {
cfg.RegisterCleanup(registry.CloseAll)
}
if err := runtime.Set("__gopeed_setFingerprint", func(fingerprint string) {
_ = runtime.Set(FingerprintMagicKey, fingerprint)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_fetch_open", func(call goja.FunctionCall) goja.Value {
request, err := exportRequest(runtime, call.Argument(0))
if err != nil {
panic(runtime.NewGoError(err))
}
fingerprint := util.SafeGet[string](runtime, FingerprintMagicKey)
promise, resolve, reject := runtime.NewPromise()
go func() {
metadata, err := registry.Open(fingerprint, cfg.ProxyHandler, request)
ok := loop.RunOnLoop(func(runtime *goja.Runtime) {
if err != nil {
reject(runtime.NewGoError(err))
return
}
resolve(runtime.ToValue(metadata))
})
if !ok && metadata != nil {
registry.Close(metadata.ID)
}
}()
return runtime.ToValue(promise)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_fetch_read", func(call goja.FunctionCall) goja.Value {
id := call.Argument(0).String()
chunkSize := int(call.Argument(1).ToInteger())
promise, resolve, reject := runtime.NewPromise()
go func() {
chunk, done, err := registry.Read(id, chunkSize)
ok := loop.RunOnLoop(func(runtime *goja.Runtime) {
if err != nil {
reject(runtime.NewGoError(err))
return
}
if done {
resolve(goja.Null())
return
}
resolve(runtime.ToValue(runtime.NewArrayBuffer(chunk)))
})
if !ok {
registry.Close(id)
}
}()
return runtime.ToValue(promise)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_fetch_close", func(id string) {
registry.Close(id)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_fetch_abort", func(id, reason string) {
registry.Abort(id, reason)
}); err != nil {
return err
}
_, err := runtime.RunString(script)
return err
}
type request struct {
ID string
URL string
Method string
Headers [][2]string
Body any
Redirect string
Credentials string
}
type responseMetadata struct {
ID string `json:"id"`
Status int `json:"status"`
StatusText string `json:"statusText"`
URL string `json:"url"`
Redirected bool `json:"redirected"`
Headers [][2]string `json:"headers"`
}
type formDataEntry struct {
name string
value any
}
type formDataSnapshot struct {
entries []formDataEntry
boundary string
}
func exportRequest(runtime *goja.Runtime, value goja.Value) (*request, error) {
obj := value.ToObject(runtime)
if obj == nil {
return nil, fmt.Errorf("invalid fetch request")
}
result := &request{
ID: obj.Get("id").String(),
URL: obj.Get("url").String(),
Method: obj.Get("method").String(),
Redirect: obj.Get("redirect").String(),
Credentials: obj.Get("credentials").String(),
}
if result.Method == "" {
result.Method = http.MethodGet
}
if headersValue := obj.Get("headers"); headersValue != nil && !goja.IsUndefined(headersValue) && !goja.IsNull(headersValue) {
if exported, ok := headersValue.Export().([]any); ok {
for _, item := range exported {
pair, ok := item.([]any)
if !ok || len(pair) != 2 {
continue
}
result.Headers = append(result.Headers, [2]string{fmt.Sprint(pair[0]), fmt.Sprint(pair[1])})
}
}
}
bodyValue := obj.Get("body")
if bodyValue != nil && !goja.IsUndefined(bodyValue) && !goja.IsNull(bodyValue) {
var body any
var err error
if obj.Get("bodyType").String() == "formdata" {
body, err = snapshotFormData(bodyValue.Export())
} else {
body, err = snapshotBody(bodyValue.Export())
}
if err != nil {
return nil, err
}
if snapshot, ok := body.(*formDataSnapshot); ok {
snapshot.boundary = obj.Get("bodyBoundary").String()
}
result.Body = body
}
return result, nil
}
// snapshotBody runs on the JavaScript event loop. Go request goroutines must
// not retain mutable ArrayBuffer or FormData storage owned by goja.
func snapshotBody(body any) (any, error) {
switch value := body.(type) {
case nil, string:
return value, nil
case []byte:
return append([]byte(nil), value...), nil
case goja.ArrayBuffer:
return append([]byte(nil), value.Bytes()...), nil
case *file.File:
return snapshotFile(value), nil
default:
if bytesValue, ok := value.(interface{ Bytes() []byte }); ok {
return append([]byte(nil), bytesValue.Bytes()...), nil
}
return value, nil
}
}
func snapshotFormData(body any) (*formDataSnapshot, error) {
entries, ok := body.([]any)
if !ok {
return nil, fmt.Errorf("invalid FormData entry list %T", body)
}
snapshot := &formDataSnapshot{entries: make([]formDataEntry, 0, len(entries))}
for _, item := range entries {
pair, ok := item.([]any)
if !ok || len(pair) != 2 {
return nil, fmt.Errorf("invalid FormData entry %T", item)
}
name, ok := pair[0].(string)
if !ok {
return nil, fmt.Errorf("invalid FormData field name %T", pair[0])
}
value := pair[1]
if formFile, ok := value.(*file.File); ok {
value = snapshotFile(formFile)
}
snapshot.entries = append(snapshot.entries, formDataEntry{name: name, value: value})
}
return snapshot, nil
}
func snapshotFile(source *file.File) *file.File {
if source == nil {
return nil
}
return &file.File{
Reader: source.Reader,
Closer: source.Closer,
Name: source.Name,
Size: source.Size,
}
}
type registry struct {
mu sync.Mutex
operations map[string]*operation
ctx context.Context
cancel context.CancelFunc
jar http.CookieJar
closed bool
}
type operation struct {
ctx context.Context
cancel context.CancelFunc
readMu sync.Mutex
stateMu sync.Mutex
body io.ReadCloser
pending error
closed bool
closeOnce sync.Once
}
func newRegistry() *registry {
ctx, cancel := context.WithCancel(context.Background())
jar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
return &registry{
operations: make(map[string]*operation),
ctx: ctx,
cancel: cancel,
jar: jar,
}
}
func (r *registry) Open(fingerprint string, proxyHandler func(*http.Request) (*url.URL, error), metadata *request) (*responseMetadata, error) {
ctx, cancel := context.WithCancel(r.ctx)
op := &operation{ctx: ctx, cancel: cancel}
if err := r.add(metadata.ID, op); err != nil {
cancel()
return nil, err
}
keepOperation := false
defer func() {
if !keepOperation {
r.Close(metadata.ID)
}
}()
contentType, body, contentLength, err := buildBody(metadata.Body)
if err != nil {
return nil, err
}
if metadata.Method == http.MethodGet || metadata.Method == http.MethodHead {
body = nil
contentLength = 0
}
httpRequest, err := http.NewRequestWithContext(ctx, metadata.Method, metadata.URL, body)
if err != nil {
return nil, err
}
for _, header := range metadata.Headers {
httpRequest.Header.Add(header[0], header[1])
}
if host := httpRequest.Header.Get("Host"); host != "" {
httpRequest.Host = host
}
if body != nil {
if contentType != "" && !hasHeader(metadata.Headers, "Content-Type") {
httpRequest.Header.Set("Content-Type", contentType)
}
httpRequest.ContentLength = contentLength
}
var jar http.CookieJar
if metadata.Credentials != "omit" {
jar = r.jar
}
redirected := false
client, err := httpclient.NewClient(httpclient.Options{
Client: httpclient.ClientOptions{
Jar: jar,
CheckRedirect: func(_ *http.Request, via []*http.Request) error {
switch metadata.Redirect {
case "manual":
return http.ErrUseLastResponse
case "error":
return fmt.Errorf("redirect failed")
default:
redirected = len(via) > 0
if len(via) > 20 {
return fmt.Errorf("too many redirects")
}
return nil
}
},
},
Transport: httpclient.TransportOptions{Proxy: proxyHandler},
Impersonation: impersonationOptions(fingerprint),
})
if err != nil {
return nil, err
}
response, err := client.Do(httpRequest)
if err != nil {
var networkError net.Error
if errors.As(err, &networkError) && networkError.Timeout() {
return nil, fmt.Errorf("Network request timed out")
}
return nil, fmt.Errorf("Network request failed: %w", err)
}
if !op.setBody(response.Body) {
return nil, context.Canceled
}
keepOperation = true
result := &responseMetadata{
ID: metadata.ID,
Status: response.StatusCode,
StatusText: http.StatusText(response.StatusCode),
URL: metadata.URL,
}
if response.Request != nil && response.Request.URL != nil {
responseURL := *response.Request.URL
responseURL.Fragment = ""
result.URL = responseURL.String()
}
result.Redirected = redirected
for key, values := range response.Header {
for _, value := range values {
result.Headers = append(result.Headers, [2]string{key, value})
}
}
return result, nil
}
func (r *registry) add(id string, op *operation) error {
if id == "" {
return fmt.Errorf("fetch request ID is required")
}
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
return context.Canceled
}
if _, exists := r.operations[id]; exists {
return fmt.Errorf("duplicate fetch request ID %q", id)
}
r.operations[id] = op
return nil
}
func (r *registry) Read(id string, chunkSize int) ([]byte, bool, error) {
op := r.get(id)
if op == nil {
return nil, true, nil
}
chunk, done, err := op.read(chunkSize)
if done || err != nil {
r.Close(id)
}
return chunk, done, err
}
func (r *registry) Close(id string) {
r.mu.Lock()
op := r.operations[id]
delete(r.operations, id)
r.mu.Unlock()
if op != nil {
op.close()
}
}
func (r *registry) Abort(id, _ string) {
r.Close(id)
}
func (r *registry) CloseAll() {
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return
}
r.closed = true
operations := r.operations
r.operations = make(map[string]*operation)
r.mu.Unlock()
r.cancel()
for _, op := range operations {
op.close()
}
}
func (r *registry) get(id string) *operation {
r.mu.Lock()
defer r.mu.Unlock()
return r.operations[id]
}
func (o *operation) setBody(body io.ReadCloser) bool {
o.stateMu.Lock()
defer o.stateMu.Unlock()
if o.closed {
_ = body.Close()
return false
}
o.body = body
return true
}
func (o *operation) read(chunkSize int) ([]byte, bool, error) {
o.readMu.Lock()
defer o.readMu.Unlock()
o.stateMu.Lock()
body := o.body
pending := o.pending
o.pending = nil
closed := o.closed
o.stateMu.Unlock()
if pending != nil {
if errors.Is(pending, io.EOF) {
return nil, true, nil
}
return nil, false, pending
}
if closed || body == nil {
return nil, true, nil
}
if chunkSize <= 0 {
chunkSize = 64 * 1024
}
buffer := make([]byte, chunkSize)
n, err := body.Read(buffer)
if n > 0 {
if err != nil {
o.stateMu.Lock()
o.pending = err
o.stateMu.Unlock()
}
return buffer[:n], false, nil
}
if errors.Is(err, io.EOF) {
return nil, true, nil
}
if err != nil {
return nil, false, err
}
return []byte{}, false, nil
}
func (o *operation) close() {
o.closeOnce.Do(func() {
o.cancel()
o.stateMu.Lock()
o.closed = true
body := o.body
o.stateMu.Unlock()
if body != nil {
_ = body.Close()
}
})
}
func buildBody(body any) (string, io.Reader, int64, error) {
switch value := body.(type) {
case nil:
return "", nil, 0, nil
case string:
return "text/plain;charset=UTF-8", strings.NewReader(value), int64(len(value)), nil
case []byte:
return "", bytes.NewReader(value), int64(len(value)), nil
case goja.ArrayBuffer:
data := value.Bytes()
return "", bytes.NewReader(data), int64(len(data)), nil
case *file.File:
return "application/octet-stream", value.Reader, value.Size, nil
case *formDataSnapshot:
return buildMultipartBody(value)
default:
if bytesValue, ok := value.(interface{ Bytes() []byte }); ok {
data := bytesValue.Bytes()
return "", bytes.NewReader(data), int64(len(data)), nil
}
data := fmt.Sprint(value)
return "", strings.NewReader(data), int64(len(data)), nil
}
}
type countingWriter struct {
n int64
}
func (w *countingWriter) Write(data []byte) (int, error) {
w.n += int64(len(data))
return len(data), nil
}
func buildMultipartBody(snapshot *formDataSnapshot) (string, io.Reader, int64, error) {
boundary := snapshot.boundary
if boundary == "" {
boundaryWriter := multipart.NewWriter(io.Discard)
boundary = boundaryWriter.Boundary()
_ = boundaryWriter.Close()
}
counter := &countingWriter{}
statWriter := multipart.NewWriter(counter)
if err := statWriter.SetBoundary(boundary); err != nil {
return "", nil, 0, err
}
for _, entry := range snapshot.entries {
switch value := entry.value.(type) {
case string:
if err := statWriter.WriteField(entry.name, value); err != nil {
return "", nil, 0, err
}
case *file.File:
if _, err := statWriter.CreateFormFile(entry.name, value.Name); err != nil {
return "", nil, 0, err
}
counter.n += value.Size
}
}
if err := statWriter.Close(); err != nil {
return "", nil, 0, err
}
reader, writer := io.Pipe()
multipartWriter := multipart.NewWriter(writer)
if err := multipartWriter.SetBoundary(boundary); err != nil {
_ = reader.Close()
_ = writer.CloseWithError(err)
return "", nil, 0, err
}
go func() {
for _, entry := range snapshot.entries {
var err error
switch value := entry.value.(type) {
case string:
err = multipartWriter.WriteField(entry.name, value)
case *file.File:
var part io.Writer
part, err = multipartWriter.CreateFormFile(entry.name, value.Name)
if err == nil {
_, err = io.Copy(part, value)
}
}
if err != nil {
_ = writer.CloseWithError(err)
return
}
}
if err := multipartWriter.Close(); err != nil {
_ = writer.CloseWithError(err)
return
}
_ = writer.Close()
}()
return multipartWriter.FormDataContentType(), reader, counter.n, nil
}
func hasHeader(headers [][2]string, key string) bool {
for _, header := range headers {
if strings.EqualFold(header[0], key) {
return true
}
}
return false
}
func impersonationOptions(fingerprint string) httpclient.ImpersonationOptions {
switch fingerprint {
case "chrome":
return httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationFixed, Browser: httpclient.BrowserChrome}
case "firefox":
return httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationFixed, Browser: httpclient.BrowserFirefox}
case "safari":
return httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationFixed, Browser: httpclient.BrowserSafari}
default:
return httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationNative}
}
}
@@ -1,68 +0,0 @@
package formdata
import "github.com/dop251/goja"
type FormData struct {
data map[string]any
}
func (fd *FormData) Append(name string, value any) {
fd.data[name] = value
}
func (fd *FormData) Delete(name string) {
delete(fd.data, name)
}
func (fd *FormData) Entries() []any {
var entries []any
for k, v := range fd.data {
entries = append(entries, []any{k, v})
}
return entries
}
func (fd *FormData) Get(name string) any {
return fd.data[name]
}
func (fd *FormData) GetAll(name string) []any {
return []any{fd.data[name]}
}
func (fd *FormData) Has(name string) bool {
_, ok := fd.data[name]
return ok
}
func (fd *FormData) Keys() []string {
var keys []string
for k := range fd.data {
keys = append(keys, k)
}
return keys
}
func (fd *FormData) Set(name string, value any) {
fd.data[name] = value
}
func (fd *FormData) Values() []any {
var values []any
for _, v := range fd.data {
values = append(values, v)
}
return values
}
func Enable(runtime *goja.Runtime) error {
file := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
instance := &FormData{
data: make(map[string]any),
}
instanceValue := runtime.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
return runtime.Set("FormData", file)
}
-513
View File
@@ -6,21 +6,11 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/xhr"
"github.com/GopeedLab/gopeed/pkg/download/engine/util"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/eventloop"
"github.com/imroc/req/v3"
)
//go:embed stream.js
@@ -29,8 +19,6 @@ var script string
type Config struct {
CreateObjectURL func(opts *ObjectURLOptions, open ObjectURLOpener) (string, error)
RevokeObjectURL func(url string) error
ProxyHandler func(r *http.Request) (*url.URL, error)
RegisterCleanup func(cleanup func())
}
type ObjectURLOptions struct {
@@ -96,69 +84,6 @@ func Enable(runtime *goja.Runtime, loop *eventloop.EventLoop, cfg *Config) error
}); err != nil {
return err
}
fetchRegistry := newFetchRegistry()
if cfg.RegisterCleanup != nil {
cfg.RegisterCleanup(fetchRegistry.CloseAll)
}
if err := runtime.Set("__gopeed_fetch_open", func(call goja.FunctionCall) goja.Value {
reqMeta, err := exportFetchRequest(runtime, call.Argument(0))
if err != nil {
panic(runtime.NewGoError(err))
}
fingerprint := util.SafeGet[string](runtime, xhr.FingerprintMagicKey)
promise, resolve, reject := runtime.NewPromise()
go func() {
meta, err := fetchRegistry.Open(fingerprint, cfg.ProxyHandler, reqMeta)
ok := loop.RunOnLoop(func(runtime *goja.Runtime) {
if err != nil {
reject(runtime.NewGoError(err))
return
}
resolve(runtime.ToValue(meta))
})
if !ok && meta != nil {
fetchRegistry.Close(meta.ID)
}
}()
return runtime.ToValue(promise)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_fetch_read", func(call goja.FunctionCall) goja.Value {
id := call.Argument(0).String()
chunkSize := int(call.Argument(1).ToInteger())
promise, resolve, reject := runtime.NewPromise()
go func() {
chunk, done, err := fetchRegistry.Read(id, chunkSize)
ok := loop.RunOnLoop(func(runtime *goja.Runtime) {
if err != nil {
reject(runtime.NewGoError(err))
return
}
if done {
resolve(goja.Null())
return
}
resolve(runtime.ToValue(runtime.NewArrayBuffer(chunk)))
})
if !ok {
fetchRegistry.Close(id)
}
}()
return runtime.ToValue(promise)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_fetch_close", func(id string) {
fetchRegistry.Close(id)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_fetch_abort", func(id string, reason string) {
fetchRegistry.Abort(id, reason)
}); err != nil {
return err
}
if err := runtime.Set("__gopeed_blob_pipe_chunk", func(call goja.FunctionCall) goja.Value {
id := call.Argument(0).String()
ok := blobPipes.Push(id, exportBytes(call.Argument(1)))
@@ -550,441 +475,3 @@ func exportJSError(value goja.Value) error {
}
return errors.New(stack)
}
type fetchRegistry struct {
mu sync.Mutex
streams map[string]*fetchStream
ctx context.Context
cancel context.CancelFunc
closed bool
}
type fetchStream struct {
body io.ReadCloser
cancel context.CancelFunc
closeOnce sync.Once
ctx context.Context
ch chan fetchChunk
readMu sync.Mutex
pending []byte
}
type fetchChunk struct {
data []byte
err error
}
type fetchRequest struct {
URL string
Method string
Headers [][2]string
Body any
Redirect string
Credentials string
}
type fetchFormDataEntry struct {
name string
value any
}
type fetchFormDataSnapshot struct {
entries []fetchFormDataEntry
}
type fetchOpenMeta struct {
ID string `json:"id"`
Status int `json:"status"`
StatusText string `json:"statusText"`
URL string `json:"url"`
Headers [][2]string `json:"headers"`
}
func newFetchRegistry() *fetchRegistry {
ctx, cancel := context.WithCancel(context.Background())
return &fetchRegistry{
streams: make(map[string]*fetchStream),
ctx: ctx,
cancel: cancel,
}
}
func exportFetchRequest(runtime *goja.Runtime, value goja.Value) (*fetchRequest, error) {
obj := value.ToObject(runtime)
if obj == nil {
return nil, fmt.Errorf("invalid fetch request")
}
meta := &fetchRequest{
URL: obj.Get("url").String(),
Method: obj.Get("method").String(),
Redirect: obj.Get("redirect").String(),
Credentials: obj.Get("credentials").String(),
}
if meta.Method == "" {
meta.Method = http.MethodGet
}
if headersVal := obj.Get("headers"); headersVal != nil && !goja.IsUndefined(headersVal) && !goja.IsNull(headersVal) {
if exported, ok := headersVal.Export().([]any); ok {
for _, item := range exported {
pair, ok := item.([]any)
if !ok || len(pair) != 2 {
continue
}
meta.Headers = append(meta.Headers, [2]string{fmt.Sprint(pair[0]), fmt.Sprint(pair[1])})
}
}
}
bodyVal := obj.Get("body")
if bodyVal != nil && !goja.IsUndefined(bodyVal) && !goja.IsNull(bodyVal) {
body, err := snapshotFetchBody(bodyVal.Export())
if err != nil {
return nil, err
}
meta.Body = body
}
return meta, nil
}
// snapshotFetchBody runs on the JavaScript event loop before fetch work is
// handed to a background goroutine. Values exported by goja may still share
// mutable storage with JavaScript (notably ArrayBuffer and FormData), so the
// background request must only retain immutable Go-owned data.
func snapshotFetchBody(body any) (any, error) {
switch v := body.(type) {
case nil, string:
return v, nil
case []byte:
return append([]byte(nil), v...), nil
case goja.ArrayBuffer:
return append([]byte(nil), v.Bytes()...), nil
case *file.File:
return snapshotFetchFile(v), nil
case *formdata.FormData:
entries := v.Entries()
snapshot := &fetchFormDataSnapshot{
entries: make([]fetchFormDataEntry, 0, len(entries)),
}
for _, entry := range entries {
pair, ok := entry.([]any)
if !ok || len(pair) != 2 {
return nil, fmt.Errorf("invalid FormData entry %T", entry)
}
name, ok := pair[0].(string)
if !ok {
return nil, fmt.Errorf("invalid FormData field name %T", pair[0])
}
value := pair[1]
if formFile, ok := value.(*file.File); ok {
value = snapshotFetchFile(formFile)
}
snapshot.entries = append(snapshot.entries, fetchFormDataEntry{
name: name,
value: value,
})
}
return snapshot, nil
default:
if typed, ok := v.(interface{ Bytes() []byte }); ok {
return append([]byte(nil), typed.Bytes()...), nil
}
return v, nil
}
}
func snapshotFetchFile(src *file.File) *file.File {
if src == nil {
return nil
}
return &file.File{
Reader: src.Reader,
Closer: src.Closer,
Name: src.Name,
Size: src.Size,
}
}
func (r *fetchRegistry) Open(fingerprint string, proxyHandler func(r *http.Request) (*url.URL, error), reqMeta *fetchRequest) (*fetchOpenMeta, error) {
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return nil, context.Canceled
}
registryCtx := r.ctx
r.mu.Unlock()
client := req.C()
if proxyHandler != nil {
client.SetProxy(proxyHandler)
}
setFetchFingerprint(client, fingerprint)
contentType, body, err := buildFetchBody(reqMeta.Body)
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(registryCtx)
keepCancel := false
defer func() {
if !keepCancel {
cancel()
}
}()
reqBuilder := client.R()
reqBuilder.SetContext(ctx)
reqBuilder.DisableAutoReadResponse()
for _, header := range reqMeta.Headers {
reqBuilder.SetHeader(header[0], header[1])
}
if body != nil && reqMeta.Method != http.MethodGet && reqMeta.Method != http.MethodHead {
reqBuilder.SetBody(body)
if contentType != "" && !hasHeader(reqMeta.Headers, "Content-Type") {
reqBuilder.SetHeader("Content-Type", contentType)
}
}
client.SetRedirectPolicy(func(req *http.Request, via []*http.Request) error {
switch reqMeta.Redirect {
case "manual":
return http.ErrUseLastResponse
case "error":
return fmt.Errorf("redirect failed")
default:
if len(via) > 20 {
return fmt.Errorf("too many redirects")
}
return nil
}
})
resp, err := reqBuilder.Send(reqMeta.Method, reqMeta.URL)
if err != nil {
var ne net.Error
if errorsAsTimeout(err, &ne) {
return nil, fmt.Errorf("Network request timed out")
}
return nil, fmt.Errorf("Network request failed: %w", err)
}
id := fmt.Sprintf("%d", time.Now().UnixNano())
meta := &fetchOpenMeta{
ID: id,
Status: resp.StatusCode,
StatusText: resp.Status,
URL: reqMeta.URL,
}
if resp.Response != nil && resp.Response.Request != nil && resp.Response.Request.URL != nil {
responseURL := *resp.Response.Request.URL
responseURL.Fragment = ""
meta.URL = responseURL.String()
}
for key, values := range resp.Header {
meta.Headers = append(meta.Headers, [2]string{key, strings.Join(values, ", ")})
}
bodyCloser := io.NopCloser(strings.NewReader(""))
if resp.Response != nil && resp.Response.Body != nil {
bodyCloser = resp.Response.Body
}
stream := &fetchStream{
body: bodyCloser,
cancel: cancel,
ctx: ctx,
ch: make(chan fetchChunk, 8),
}
r.mu.Lock()
if r.closed {
r.mu.Unlock()
closeFetchStream(stream)
return nil, context.Canceled
}
r.streams[id] = stream
r.mu.Unlock()
keepCancel = true
go stream.readLoop()
return meta, nil
}
func (r *fetchRegistry) Read(id string, chunkSize int) ([]byte, bool, error) {
stream := r.get(id)
if stream == nil {
return nil, true, nil
}
stream.readMu.Lock()
defer stream.readMu.Unlock()
if len(stream.pending) > 0 {
n := len(stream.pending)
if chunkSize > 0 && n > chunkSize {
n = chunkSize
}
chunk := append([]byte(nil), stream.pending[:n]...)
stream.pending = stream.pending[n:]
return chunk, false, nil
}
select {
case item, ok := <-stream.ch:
if !ok {
r.Close(id)
return nil, true, nil
}
if item.err != nil {
r.Close(id)
return nil, false, item.err
}
if chunkSize > 0 && len(item.data) > chunkSize {
head := append([]byte(nil), item.data[:chunkSize]...)
stream.pending = append(stream.pending[:0], item.data[chunkSize:]...)
return head, false, nil
}
return item.data, false, nil
case <-stream.ctx.Done():
return nil, false, stream.ctx.Err()
}
}
func (s *fetchStream) readLoop() {
defer close(s.ch)
buf := make([]byte, 64*1024)
for {
n, err := s.body.Read(buf)
if n > 0 {
chunk := append([]byte(nil), buf[:n]...)
select {
case s.ch <- fetchChunk{data: chunk}:
case <-s.ctx.Done():
return
}
}
if err == io.EOF {
return
}
if err != nil {
select {
case s.ch <- fetchChunk{err: err}:
case <-s.ctx.Done():
}
return
}
}
}
func (r *fetchRegistry) Close(id string) {
r.mu.Lock()
stream := r.streams[id]
delete(r.streams, id)
r.mu.Unlock()
closeFetchStream(stream)
}
// CloseAll permanently closes the registry and cancels both active and
// in-flight fetches. It is safe to call more than once.
func (r *fetchRegistry) CloseAll() {
if r == nil {
return
}
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return
}
r.closed = true
if r.cancel != nil {
r.cancel()
}
streams := r.streams
r.streams = make(map[string]*fetchStream)
r.mu.Unlock()
for _, stream := range streams {
closeFetchStream(stream)
}
}
func closeFetchStream(stream *fetchStream) {
if stream == nil {
return
}
stream.closeOnce.Do(func() {
if stream.cancel != nil {
stream.cancel()
}
_ = stream.body.Close()
})
}
func (r *fetchRegistry) Abort(id string, _ string) {
r.Close(id)
}
func (r *fetchRegistry) get(id string) *fetchStream {
r.mu.Lock()
defer r.mu.Unlock()
return r.streams[id]
}
func buildFetchBody(body any) (string, any, error) {
switch v := body.(type) {
case nil:
return "", nil, nil
case string:
return "text/plain;charset=UTF-8", v, nil
case []byte:
return "application/octet-stream", v, nil
case goja.ArrayBuffer:
return "application/octet-stream", v.Bytes(), nil
case *file.File:
return "application/octet-stream", v.Reader, nil
case *fetchFormDataSnapshot:
pr, pw := io.Pipe()
mw := xhr.NewMultipart(pw)
for _, entry := range v.entries {
switch vv := entry.value.(type) {
case string:
if err := mw.WriteField(entry.name, vv); err != nil {
_ = pw.CloseWithError(err)
return "", nil, err
}
case *file.File:
if err := mw.WriteFile(entry.name, vv); err != nil {
_ = pw.CloseWithError(err)
return "", nil, err
}
}
}
go func() {
if err := mw.Send(); err != nil {
_ = pw.CloseWithError(err)
return
}
if err := mw.Close(); err != nil {
_ = pw.CloseWithError(err)
return
}
_ = pw.Close()
}()
return mw.FormDataContentType(), pr, nil
default:
if typed, ok := v.(interface{ Bytes() []byte }); ok {
return "application/octet-stream", typed.Bytes(), nil
}
return "", fmt.Sprint(v), nil
}
}
func hasHeader(headers [][2]string, key string) bool {
for _, header := range headers {
if strings.EqualFold(header[0], key) {
return true
}
}
return false
}
func setFetchFingerprint(client *req.Client, fingerprint string) {
switch fingerprint {
case "chrome":
client.ImpersonateChrome()
case "firefox":
client.ImpersonateFirefox()
case "safari":
client.ImpersonateSafari()
}
}
func errorsAsTimeout(err error, target *net.Error) bool {
return errors.As(err, target) && (*target).Timeout()
}
@@ -258,7 +258,6 @@ func waitForJSValue(t *testing.T, engine *engine.Engine, expression, want string
func newStreamTestEngine(t *testing.T, created chan<- createdObjectURL) (*engine.Engine, func()) {
t.Helper()
var next atomic.Int64
var cleanup func()
e := engine.NewEngine(&engine.Config{
StreamConfig: &stream.Config{
CreateObjectURL: func(opts *stream.ObjectURLOptions, open stream.ObjectURLOpener) (string, error) {
@@ -266,13 +265,7 @@ func newStreamTestEngine(t *testing.T, created chan<- createdObjectURL) (*engine
return fmt.Sprintf("blob:test-%d", next.Add(1)), nil
},
RevokeObjectURL: func(string) error { return nil },
RegisterCleanup: func(fn func()) {
cleanup = fn
},
},
})
if cleanup == nil {
t.Fatal("stream cleanup was not registered")
}
return e, cleanup
return e, e.Close
}
@@ -1,191 +0,0 @@
package stream
import (
"context"
"io"
"strings"
"sync/atomic"
"testing"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
"github.com/dop251/goja"
)
func TestExportFetchRequestSnapshotsArrayBuffer(t *testing.T) {
runtime := goja.New()
buffer := runtime.NewArrayBuffer([]byte("before"))
request := runtime.NewObject()
setFetchRequestDefaults(t, request)
if err := request.Set("body", buffer); err != nil {
t.Fatal(err)
}
meta, err := exportFetchRequest(runtime, request)
if err != nil {
t.Fatal(err)
}
buffer.Bytes()[0] = 'X'
body, ok := meta.Body.([]byte)
if !ok {
t.Fatalf("expected byte snapshot, got %T", meta.Body)
}
if got := string(body); got != "before" {
t.Fatalf("ArrayBuffer snapshot changed after export: %q", got)
}
}
func TestExportFetchRequestSnapshotsFormData(t *testing.T) {
runtime := goja.New()
runtime.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
if err := formdata.Enable(runtime); err != nil {
t.Fatal(err)
}
value, err := runtime.RunString(`
globalThis.form = new FormData();
form.append("field", "before");
form;
`)
if err != nil {
t.Fatal(err)
}
request := runtime.NewObject()
setFetchRequestDefaults(t, request)
if err := request.Set("body", value); err != nil {
t.Fatal(err)
}
meta, err := exportFetchRequest(runtime, request)
if err != nil {
t.Fatal(err)
}
if _, err := runtime.RunString(`form.set("field", "after")`); err != nil {
t.Fatal(err)
}
body, ok := meta.Body.(*fetchFormDataSnapshot)
if !ok {
t.Fatalf("expected FormData snapshot, got %T", meta.Body)
}
if len(body.entries) != 1 {
t.Fatalf("expected one FormData entry, got %d", len(body.entries))
}
if got := body.entries[0]; got.name != "field" || got.value != "before" {
t.Fatalf("unexpected FormData snapshot: %#v", got)
}
}
func setFetchRequestDefaults(t *testing.T, request *goja.Object) {
t.Helper()
for key, value := range map[string]any{
"url": "https://example.test",
"method": "POST",
"redirect": "follow",
"credentials": "same-origin",
} {
if err := request.Set(key, value); err != nil {
t.Fatal(err)
}
}
}
func TestSnapshotFetchBodyCopiesFileMetadata(t *testing.T) {
source := &file.File{
Reader: strings.NewReader("payload"),
Name: "before.txt",
Size: 7,
}
value, err := snapshotFetchBody(source)
if err != nil {
t.Fatal(err)
}
snapshot, ok := value.(*file.File)
if !ok {
t.Fatalf("expected file snapshot, got %T", value)
}
source.Name = "after.txt"
source.Size = 99
if snapshot.Name != "before.txt" || snapshot.Size != 7 {
t.Fatalf("file metadata was not snapshotted: %#v", snapshot)
}
}
func TestFetchRegistryCloseAll(t *testing.T) {
registry := newFetchRegistry()
streamCtx, cancel := context.WithCancel(context.Background())
body := &trackingReadCloser{}
registry.streams["active"] = &fetchStream{
body: body,
cancel: cancel,
ctx: streamCtx,
ch: make(chan fetchChunk),
}
registry.CloseAll()
registry.CloseAll()
select {
case <-streamCtx.Done():
default:
t.Fatal("active fetch context was not canceled")
}
if got := body.closeCalls.Load(); got != 1 {
t.Fatalf("expected body to close once, got %d", got)
}
registry.mu.Lock()
closed := registry.closed
remaining := len(registry.streams)
registry.mu.Unlock()
if !closed || remaining != 0 {
t.Fatalf("registry not fully closed: closed=%v remaining=%d", closed, remaining)
}
if _, err := registry.Open("", nil, &fetchRequest{URL: "https://example.test"}); err == nil {
t.Fatal("expected a closed registry to reject new fetches")
}
}
func TestFetchRegistryReadPreservesChunkOrder(t *testing.T) {
registry := newFetchRegistry()
ctx, cancel := context.WithCancel(context.Background())
stream := &fetchStream{
body: io.NopCloser(strings.NewReader("")),
cancel: cancel,
ctx: ctx,
ch: make(chan fetchChunk, 2),
}
stream.ch <- fetchChunk{data: []byte("abc")}
stream.ch <- fetchChunk{data: []byte("def")}
registry.streams["ordered"] = stream
defer registry.CloseAll()
var got strings.Builder
for range 4 {
chunk, done, err := registry.Read("ordered", 2)
if err != nil {
t.Fatal(err)
}
if done {
t.Fatal("stream ended before buffered chunks were read")
}
got.Write(chunk)
}
if got.String() != "abcdef" {
t.Fatalf("fetch chunks were reordered: %q", got.String())
}
}
type trackingReadCloser struct {
closeCalls atomic.Int32
}
func (r *trackingReadCloser) Read([]byte) (int, error) {
return 0, io.EOF
}
func (r *trackingReadCloser) Close() error {
r.closeCalls.Add(1)
return nil
}
+90 -244
View File
@@ -1,10 +1,6 @@
(function () {
const createBlobObjectURL = globalThis.__gopeed_create_blob_object_url;
const revokeObjectURL = globalThis.__gopeed_revoke_object_url;
const fetchOpen = globalThis.__gopeed_fetch_open;
const fetchRead = globalThis.__gopeed_fetch_read;
const fetchClose = globalThis.__gopeed_fetch_close;
const fetchAbort = globalThis.__gopeed_fetch_abort;
if (typeof globalThis.ReadableStream === "undefined") {
class ReadableStreamDefaultController {
@@ -30,7 +26,10 @@
this._stream = stream;
}
read() {
read(view) {
if (view !== undefined) {
return this._stream._readInto(view);
}
return this._stream._read();
}
@@ -59,6 +58,7 @@
this._errored = null;
this._reader = null;
this._pulling = false;
this._disturbed = false;
this.locked = false;
this._controller = new ReadableStreamDefaultController(this);
if (typeof source.start === "function") {
@@ -96,6 +96,7 @@
if (this._errored) {
return Promise.reject(this._errored);
}
this._disturbed = true;
this._markBodyUsed();
if (this._queue.length > 0) {
return Promise.resolve({ done: false, value: this._queue.shift() });
@@ -116,6 +117,26 @@
});
}
async _readInto(view) {
if (!ArrayBuffer.isView(view) || view.byteLength === 0) {
throw new TypeError("BYOB reads require a non-empty ArrayBufferView");
}
const result = await this._read();
if (result.done) {
return { done: true, value: new Uint8Array(view.buffer, view.byteOffset, 0) };
}
if (!(result.value instanceof Uint8Array)) {
throw new TypeError("Byte streams require Uint8Array chunks");
}
const length = Math.min(view.byteLength, result.value.byteLength);
const target = new Uint8Array(view.buffer, view.byteOffset, length);
target.set(result.value.subarray(0, length));
if (length < result.value.byteLength) {
this._queue.unshift(result.value.subarray(length));
}
return { done: false, value: target };
}
_maybePull() {
if (this._closed || this._errored || this._pulling) {
return;
@@ -155,6 +176,7 @@
}
cancel(reason) {
this._disturbed = true;
this._markBodyUsed();
if (this._source && typeof this._source.cancel === "function") {
return Promise.resolve(this._source.cancel(reason)).then(() => {
@@ -185,6 +207,69 @@
writer.releaseLock();
}
}
tee() {
const reader = this.getReader();
let reading = false;
let currentRead = null;
let finished = false;
const waiting = [[], []];
const queues = [[], []];
const cloneChunk = (chunk) => {
if (chunk instanceof ArrayBuffer) return chunk.slice(0);
if (ArrayBuffer.isView(chunk)) {
const buffer = chunk.buffer.slice(0);
if (chunk instanceof DataView) return new DataView(buffer, chunk.byteOffset, chunk.byteLength);
return new chunk.constructor(buffer, chunk.byteOffset, chunk.length);
}
return chunk;
};
const flush = (index) => {
while (waiting[index].length && queues[index].length) {
waiting[index].shift().enqueue(queues[index].shift());
}
if (finished && queues[index].length === 0) {
while (waiting[index].length) waiting[index].shift().close();
}
};
const pump = () => {
if (reading) return currentRead;
if (finished) return Promise.resolve();
reading = true;
currentRead = (async () => {
try {
const result = await reader.read();
if (result.done) {
finished = true;
} else {
queues[0].push(result.value);
queues[1].push(cloneChunk(result.value));
}
} catch (error) {
finished = true;
for (const branches of waiting) {
while (branches.length) branches.shift().error(error);
}
throw error;
} finally {
reading = false;
currentRead = null;
flush(0);
flush(1);
}
})();
return currentRead;
};
const branch = (index) => new ReadableStream({
pull(controller) {
waiting[index].push(controller);
flush(index);
return pump();
},
});
return [branch(0), branch(1)];
}
}
globalThis.ReadableStream = ReadableStream;
@@ -351,167 +436,6 @@
};
}
function toUint8Array(chunk) {
if (chunk == null) {
return new Uint8Array(0);
}
if (chunk instanceof Uint8Array) {
return chunk;
}
if (typeof chunk === "string") {
return new TextEncoder().encode(chunk);
}
if (chunk instanceof ArrayBuffer) {
return new Uint8Array(chunk);
}
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView && ArrayBuffer.isView(chunk)) {
return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
}
if (typeof Blob !== "undefined" && chunk instanceof Blob) {
if (chunk._buffer instanceof Uint8Array) {
return chunk._buffer;
}
}
return new Uint8Array(0);
}
function createBodyReadableStream(owner) {
return new ReadableStream({
start(controller) {
Promise.resolve().then(async () => {
owner.bodyUsed = true;
if (owner._noBody) {
controller.close();
return;
}
if (owner._bodyArrayBuffer) {
controller.enqueue(toUint8Array(owner._bodyArrayBuffer));
controller.close();
return;
}
if (owner._bodyBlob) {
const data = await owner._bodyBlob.arrayBuffer();
controller.enqueue(new Uint8Array(data));
controller.close();
return;
}
if (owner._bodyText != null) {
controller.enqueue(toUint8Array(owner._bodyText));
controller.close();
return;
}
if (owner._bodyInit != null) {
controller.enqueue(toUint8Array(owner._bodyInit));
}
controller.close();
}).catch((err) => {
controller.error(err);
});
}
});
}
async function readAllFromStream(stream, asText) {
if (!stream) {
return asText ? "" : new Uint8Array(0);
}
const reader = stream.getReader();
const chunks = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = toUint8Array(value);
chunks.push(chunk);
total += chunk.byteLength;
}
} finally {
reader.releaseLock();
}
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
if (asText) {
return new TextDecoder().decode(merged);
}
return merged;
}
function attachResponseStreaming(response, stream) {
response.__gopeedBodyStream = stream;
response.__gopeedBodyConsumed = false;
const ensureUnused = () => {
if (response.__gopeedBodyConsumed) {
throw new TypeError("Already read");
}
};
const markBodyUsed = () => {
ensureUnused();
response.__gopeedBodyConsumed = true;
response.bodyUsed = true;
};
if (stream) {
stream.__gopeedMarkBodyUsed = () => {
if (!response.__gopeedBodyConsumed) {
response.__gopeedBodyConsumed = true;
response.bodyUsed = true;
}
};
}
response.text = async function () {
ensureUnused();
markBodyUsed();
return readAllFromStream(stream, true);
};
response.arrayBuffer = async function () {
ensureUnused();
markBodyUsed();
const bytes = await readAllFromStream(stream, false);
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
};
response.blob = async function () {
ensureUnused();
markBodyUsed();
const contentType = this.headers && this.headers.get ? (this.headers.get("content-type") || "") : "";
// Response.blob() is a materializing body consumer. Keeping the live
// response stream behind an empty one-shot Blob makes retry, range and a
// second object-URL reader silently produce an empty file.
const bytes = await readAllFromStream(stream, false);
return new Blob([bytes], { type: contentType });
};
response.json = async function () {
const text = await this.text();
return JSON.parse(text);
};
return response;
}
if (typeof globalThis.Response === "function") {
const responseProto = globalThis.Response.prototype;
const bodyDescriptor = Object.getOwnPropertyDescriptor(responseProto, "body");
if (!bodyDescriptor || typeof bodyDescriptor.get !== "function") {
Object.defineProperty(responseProto, "body", {
configurable: true,
enumerable: true,
get() {
if (this.__gopeedBodyStream) {
return this.__gopeedBodyStream;
}
if (!this.__gopeedBodyStream) {
this.__gopeedBodyStream = createBodyReadableStream(this);
}
return this.__gopeedBodyStream;
}
});
}
}
const blobReaders = new Map();
function getValueTypeName(value) {
@@ -883,82 +807,4 @@
}
};
const originalFetch = typeof globalThis.fetch === "function"
? globalThis.fetch.bind(globalThis)
: null;
if (typeof fetchOpen === "function") {
globalThis.fetch = async function (input, init) {
const request = new Request(input, init);
if (originalFetch && (request.method === "HEAD" || request.redirect === "manual" || request.redirect === "error")) {
return originalFetch(input, init);
}
let body = null;
if (request._bodyFormData) {
body = request._bodyFormData;
} else if (request._bodyArrayBuffer) {
body = request._bodyArrayBuffer;
} else if (request._bodyBlob) {
body = await request._bodyBlob.arrayBuffer();
} else if (request._bodyInit != null && typeof request._bodyInit === "object") {
body = request._bodyInit;
} else if (request._bodyText != null) {
body = request._bodyText;
} else if (request._bodyInit != null) {
body = request._bodyInit;
}
const headers = [];
request.headers.forEach((value, key) => {
headers.push([key, value]);
});
let meta;
try {
meta = await fetchOpen({
url: request.url,
method: request.method,
headers,
body,
redirect: request.redirect,
credentials: request.credentials
});
} catch (error) {
throw error instanceof Error ? error : new TypeError(String(error));
}
const stream = new ReadableStream({
async pull(controller) {
let chunk;
try {
chunk = await fetchRead(meta.id, 64 * 1024);
} catch (error) {
fetchClose(meta.id);
controller.error(error instanceof Error ? error : new TypeError(String(error)));
return;
}
if (chunk == null) {
fetchClose(meta.id);
controller.close();
return;
}
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
if (bytes.byteLength === 0) {
fetchClose(meta.id);
controller.close();
return;
}
controller.enqueue(bytes);
},
cancel(reason) {
fetchAbort(meta.id, reason == null ? "" : String(reason));
}
});
const response = new Response(null, {
status: meta.status,
statusText: meta.statusText,
headers: meta.headers,
url: meta.url
});
return attachResponseStreaming(response, stream);
};
globalThis.fetch.__gopeedOriginalFetch = originalFetch;
}
})();
+6 -473
View File
@@ -1,482 +1,15 @@
package xhr
import (
"bytes"
"errors"
"io"
"mime/multipart"
"net"
"net/http"
"net/url"
"strings"
"time"
_ "embed"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/file"
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
"github.com/GopeedLab/gopeed/pkg/download/engine/util"
"github.com/dop251/goja"
"github.com/imroc/req/v3"
)
const (
eventLoad = "load"
eventReadystatechange = "readystatechange"
eventProgress = "progress"
eventAbort = "abort"
eventError = "error"
eventTimeout = "timeout"
)
//go:embed xhr.js
var script string
const (
redirectError = "error"
redirectFollow = "follow"
redirectManual = "manual"
)
type ProgressEvent struct {
Type string `json:"type"`
LengthComputable bool `json:"lengthComputable"`
Loaded int64 `json:"loaded"`
Total int64 `json:"total"`
}
type EventProp struct {
eventListeners map[string]func(event *ProgressEvent)
Onload func(event *ProgressEvent) `json:"onload"`
Onprogress func(event *ProgressEvent) `json:"onprogress"`
Onabort func(event *ProgressEvent) `json:"onabort"`
Onerror func(event *ProgressEvent) `json:"onerror"`
Ontimeout func(event *ProgressEvent) `json:"ontimeout"`
}
func (ep *EventProp) AddEventListener(event string, cb func(event *ProgressEvent)) {
ep.eventListeners[event] = cb
}
func (ep *EventProp) RemoveEventListener(event string) {
delete(ep.eventListeners, event)
}
func (ep *EventProp) callOnload() {
event := &ProgressEvent{
Type: eventLoad,
LengthComputable: false,
}
if ep.Onload != nil {
ep.Onload(event)
}
ep.callEventListener(event)
}
func (ep *EventProp) callOnprogress(loaded, total int64) {
event := &ProgressEvent{
Type: eventProgress,
LengthComputable: true,
Loaded: loaded,
Total: total,
}
if ep.Onprogress != nil {
ep.Onprogress(event)
}
ep.callEventListener(event)
}
func (ep *EventProp) callOnabort() {
event := &ProgressEvent{
Type: eventAbort,
LengthComputable: false,
}
if ep.Onabort != nil {
ep.Onabort(event)
}
ep.callEventListener(event)
}
func (ep *EventProp) callOnerror() {
event := &ProgressEvent{
Type: eventError,
LengthComputable: false,
}
if ep.Onerror != nil {
ep.Onerror(event)
}
ep.callEventListener(event)
}
func (ep *EventProp) callOntimeout() {
event := &ProgressEvent{
Type: eventTimeout,
LengthComputable: false,
}
if ep.Ontimeout != nil {
ep.Ontimeout(event)
}
ep.callEventListener(event)
}
func (ep *EventProp) callEventListener(event *ProgressEvent) {
if cb, ok := ep.eventListeners[event.Type]; ok {
cb(event)
}
}
type XMLHttpRequestUpload struct {
*EventProp
}
type XMLHttpRequest struct {
method string
url string
requestHeaders http.Header
responseHeaders http.Header
aborted bool
client *req.Client
fingerprint string
WithCredentials bool `json:"withCredentials"`
Upload *XMLHttpRequestUpload `json:"upload"`
Timeout int `json:"timeout"`
ReadyState int `json:"readyState"`
Status int `json:"status"`
StatusText string `json:"statusText"`
Response string `json:"response"`
ResponseText string `json:"responseText"`
// https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest/responseURL
// https://xhr.spec.whatwg.org/#the-responseurl-attribute
ResponseUrl string `json:"responseURL"`
// extend fetch redirect
// https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#redirect
// https://fetch.spec.whatwg.org/#concept-request-redirect-mode
Redirect string `json:"redirect"`
*EventProp
Onreadystatechange func(event *ProgressEvent) `json:"onreadystatechange"`
}
func (xhr *XMLHttpRequest) Open(method, url string) {
xhr.method = method
xhr.url = url
xhr.requestHeaders = make(http.Header)
xhr.responseHeaders = make(http.Header)
xhr.doReadystatechange(1)
}
func (xhr *XMLHttpRequest) SetRequestHeader(key, value string) {
xhr.requestHeaders.Add(key, value)
}
func (xhr *XMLHttpRequest) Send(data goja.Value) {
setFingerprint(xhr.client, xhr.fingerprint)
d := xhr.parseData(data)
var (
contentType string
contentLength int64
isStringBody bool
)
// Create request using req library
reqBuilder := xhr.client.R()
// Set headers first
if xhr.requestHeaders != nil {
for key, values := range xhr.requestHeaders {
if len(values) > 0 {
// Merge multiple values with comma separator (HTTP standard)
mergedValue := strings.Join(values, ", ")
reqBuilder.SetHeader(key, mergedValue)
}
}
}
// Handle request body
if d != nil && xhr.method != "GET" && xhr.method != "HEAD" {
switch v := d.(type) {
case string:
reqBuilder.SetBody(v)
contentType = "text/plain;charset=UTF-8"
contentLength = int64(len(v))
isStringBody = true
case *file.File:
reqBuilder.SetBody(v.Reader)
contentType = "application/octet-stream"
contentLength = v.Size
case *formdata.FormData:
pr, pw := io.Pipe()
mw := NewMultipart(pw)
for _, e := range v.Entries() {
arr := e.([]any)
k := arr[0].(string)
v := arr[1]
switch v := v.(type) {
case string:
mw.WriteField(k, v)
case *file.File:
mw.WriteFile(k, v)
}
}
go func() {
defer pw.Close()
defer mw.Close()
mw.Send()
}()
reqBuilder.SetBody(pr)
contentType = mw.FormDataContentType()
contentLength = mw.Size()
}
}
// Only string body can specify Content-Type header by user
if contentType != "" && (!isStringBody || xhr.requestHeaders.Get("Content-Type") == "") {
reqBuilder.SetHeader("Content-Type", contentType)
}
// Set timeout
if xhr.Timeout > 0 {
xhr.client.SetTimeout(time.Duration(xhr.Timeout) * time.Millisecond)
}
// Configure redirect behavior
xhr.client.SetRedirectPolicy(func(req *http.Request, via []*http.Request) error {
if xhr.Redirect == redirectManual {
return http.ErrUseLastResponse
}
if xhr.Redirect == redirectError {
return errors.New("redirect failed")
}
if len(via) > 20 {
return errors.New("too many redirects")
}
return nil
})
// Execute request
resp, err := reqBuilder.Send(xhr.method, xhr.url)
if err != nil {
// handle timeout error
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
if xhr.Timeout > 0 {
xhr.Upload.callOntimeout()
xhr.callOntimeout()
}
return
}
xhr.Upload.callOnerror()
xhr.callOnerror()
return
}
xhr.Upload.callOnprogress(contentLength, contentLength)
if !xhr.aborted {
xhr.Upload.callOnload()
}
// Set response URL (final URL after redirects)
if resp.Response != nil && resp.Response.Request != nil && resp.Response.Request.URL != nil {
responseUrl := resp.Response.Request.URL
responseUrl.Fragment = ""
xhr.ResponseUrl = responseUrl.String()
} else {
xhr.ResponseUrl = xhr.url
}
// Set response headers
xhr.responseHeaders = resp.Header
xhr.Status = resp.StatusCode
xhr.StatusText = resp.Status
xhr.doReadystatechange(2)
bodyBytes := resp.Bytes()
xhr.doReadystatechange(3)
xhr.Response = string(bodyBytes)
xhr.ResponseText = xhr.Response
xhr.doReadystatechange(4)
respBodyLen := int64(len(bodyBytes))
xhr.callOnprogress(respBodyLen, respBodyLen)
if !xhr.aborted {
xhr.callOnload()
}
}
func (xhr *XMLHttpRequest) Abort() {
xhr.doReadystatechange(0)
xhr.aborted = true
xhr.Upload.callOnabort()
xhr.callOnabort()
}
func (xhr *XMLHttpRequest) GetResponseHeader(key string) string {
if xhr.responseHeaders == nil {
return ""
}
return strings.Join(xhr.responseHeaders.Values(key), ", ")
}
func (xhr *XMLHttpRequest) GetAllResponseHeaders() string {
var buf bytes.Buffer
for k, v := range xhr.responseHeaders {
buf.WriteString(k)
buf.WriteString(": ")
buf.WriteString(strings.Join(v, ", "))
buf.WriteString("\r\n")
}
return buf.String()
}
func (xhr *XMLHttpRequest) callOnreadystatechange() {
event := &ProgressEvent{
Type: eventReadystatechange,
LengthComputable: false,
}
if xhr.Onreadystatechange != nil {
xhr.Onreadystatechange(event)
}
xhr.callEventListener(event)
}
func (xhr *XMLHttpRequest) doReadystatechange(state int) {
if xhr.aborted {
return
}
xhr.ReadyState = state
xhr.callOnreadystatechange()
}
// parse js data to go struct
func (xhr *XMLHttpRequest) parseData(data goja.Value) any {
// check if data is null or undefined
if data == nil || goja.IsNull(data) || goja.IsUndefined(data) || goja.IsNaN(data) {
return nil
}
// check if data is File
f, ok := data.Export().(*file.File)
if ok {
return f
}
// check if data is FormData
fd, ok := data.Export().(*formdata.FormData)
if ok {
return fd
}
// otherwise, return data as string
return data.String()
}
func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL, error)) error {
progressEvent := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
if len(call.Arguments) < 1 {
util.ThrowTypeError(runtime, "Failed to construct 'ProgressEvent': 1 argument required, but only 0 present.")
}
instance := &ProgressEvent{
Type: call.Argument(0).String(),
}
instanceValue := runtime.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
xhr := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
// Create req client with proxy support
client := req.C()
if proxyHandler != nil {
client.SetProxy(proxyHandler)
}
instance := &XMLHttpRequest{
client: client,
fingerprint: util.SafeGet[string](runtime, FingerprintMagicKey),
Upload: &XMLHttpRequestUpload{
EventProp: &EventProp{
eventListeners: make(map[string]func(event *ProgressEvent)),
},
},
EventProp: &EventProp{
eventListeners: make(map[string]func(event *ProgressEvent)),
},
}
instanceValue := runtime.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
runtime.Set("__gopeed_setFingerprint", func(fingerprint string) {
runtime.Set(FingerprintMagicKey, fingerprint)
})
if err := runtime.Set("ProgressEvent", progressEvent); err != nil {
return err
}
if err := runtime.Set("XMLHttpRequest", xhr); err != nil {
return err
}
return nil
}
// Wrap multipart.Writer and stat content length
type multipartWrapper struct {
statBuffer *bytes.Buffer
statWriter *multipart.Writer
writer *multipart.Writer
fields map[string]any
}
func NewMultipart(w io.Writer) *multipartWrapper {
var buf bytes.Buffer
return &multipartWrapper{
statBuffer: &buf,
statWriter: multipart.NewWriter(&buf),
writer: multipart.NewWriter(w),
fields: make(map[string]any),
}
}
func (w *multipartWrapper) WriteField(fieldname string, value string) error {
w.fields[fieldname] = value
return w.statWriter.WriteField(fieldname, value)
}
func (w *multipartWrapper) WriteFile(fieldname string, file *file.File) error {
w.fields[fieldname] = file
_, err := w.statWriter.CreateFormFile(fieldname, file.Name)
if err != nil {
return err
}
return nil
}
func (w *multipartWrapper) Size() int64 {
w.statWriter.Close()
size := int64(w.statBuffer.Len())
for _, v := range w.fields {
switch v := v.(type) {
case *file.File:
size += v.Size
}
}
return size
}
func (w *multipartWrapper) Send() error {
for k, v := range w.fields {
switch v := v.(type) {
case string:
if err := w.writer.WriteField(k, v); err != nil {
return err
}
case *file.File:
fw, err := w.writer.CreateFormFile(k, v.Name)
if err != nil {
return err
}
if _, err = io.Copy(fw, v); err != nil {
return err
}
}
}
return nil
}
func (w *multipartWrapper) FormDataContentType() string {
return w.writer.FormDataContentType()
}
func (w *multipartWrapper) Close() error {
return w.writer.Close()
func Enable(runtime *goja.Runtime) error {
_, err := runtime.RunString(script)
return err
}
@@ -1,24 +0,0 @@
package xhr
import "github.com/imroc/req/v3"
type Fingerprint string
const (
FingerprintMagicKey = "__gopeed_xhr_fingerprint"
fingerprintChrome = "chrome"
fingerprintFirefox = "firefox"
fingerprintSafari = "safari"
)
func setFingerprint(client *req.Client, fingerprint string) {
switch fingerprint {
case fingerprintChrome:
client.ImpersonateChrome()
case fingerprintFirefox:
client.ImpersonateFirefox()
case fingerprintSafari:
client.ImpersonateSafari()
}
}
+302
View File
@@ -0,0 +1,302 @@
(function () {
if (typeof globalThis.ProgressEvent === "undefined") {
globalThis.ProgressEvent = class ProgressEvent extends Event {
constructor(type, init) {
super(type);
init = init || {};
this.lengthComputable = Boolean(init.lengthComputable);
this.loaded = Number(init.loaded) || 0;
this.total = Number(init.total) || 0;
}
};
}
function createProgressEvent(type, loaded, total) {
const lengthComputable = Number.isFinite(total) && total >= 0;
return new ProgressEvent(type, {
lengthComputable,
loaded: loaded || 0,
total: lengthComputable ? total : 0,
});
}
function canonicalHeaderName(name) {
return String(name).toLowerCase().split("-").map(function (part) {
return part ? part.charAt(0).toUpperCase() + part.slice(1) : part;
}).join("-");
}
function bodySize(body) {
if (body == null) return 0;
if (typeof body === "string") return new TextEncoder().encode(body).byteLength;
if (body instanceof ArrayBuffer) return body.byteLength;
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView && ArrayBuffer.isView(body)) {
return body.byteLength;
}
if (typeof Blob !== "undefined" && body instanceof Blob) return Number(body.size) || 0;
if (body && Number.isFinite(Number(body.size))) return Number(body.size);
return 0;
}
class XMLHttpRequestUpload extends EventTarget {
constructor() {
super();
this.onloadstart = null;
this.onprogress = null;
this.onabort = null;
this.onerror = null;
this.onload = null;
this.ontimeout = null;
this.onloadend = null;
}
_emit(type, loaded, total) {
const event = createProgressEvent(type, loaded, total);
const handler = this["on" + type];
if (typeof handler === "function") handler.call(this, event);
this.dispatchEvent(event);
}
}
class XMLHttpRequest extends EventTarget {
constructor() {
super();
this.readyState = XMLHttpRequest.UNSENT;
this.response = "";
this.responseText = "";
this.responseType = "";
this.responseURL = "";
this.responseXML = null;
this.status = 0;
this.statusText = "";
this.timeout = 0;
this.withCredentials = false;
this.upload = new XMLHttpRequestUpload();
this.redirect = "follow";
this.onreadystatechange = null;
this.onloadstart = null;
this.onprogress = null;
this.onabort = null;
this.onerror = null;
this.onload = null;
this.ontimeout = null;
this.onloadend = null;
this._method = "GET";
this._url = "";
this._requestHeaders = new Headers();
this._responseHeaders = null;
this._controller = null;
this._sendStarted = false;
this._aborted = false;
this._timedOut = false;
this._timeoutID = null;
}
open(method, url, async) {
if (arguments.length < 2) {
throw new TypeError("XMLHttpRequest.open requires at least 2 arguments");
}
if (async === false) {
throw new DOMException("Synchronous XMLHttpRequest is not supported", "NotSupportedError");
}
this._method = String(method).toUpperCase();
this._url = String(url);
this._requestHeaders = new Headers();
this._responseHeaders = null;
this._sendStarted = false;
this._aborted = false;
this._timedOut = false;
this.response = "";
this.responseText = "";
this.responseURL = "";
this.status = 0;
this.statusText = "";
this._setReadyState(XMLHttpRequest.OPENED);
}
setRequestHeader(name, value) {
if (this.readyState !== XMLHttpRequest.OPENED || this._sendStarted) {
throw new DOMException("XMLHttpRequest is not opened", "InvalidStateError");
}
this._requestHeaders.append(name, value);
}
getResponseHeader(name) {
if (!this._responseHeaders || this.readyState < XMLHttpRequest.HEADERS_RECEIVED) return null;
return this._responseHeaders.get(name);
}
getAllResponseHeaders() {
if (!this._responseHeaders || this.readyState < XMLHttpRequest.HEADERS_RECEIVED) return "";
let result = "";
this._responseHeaders.forEach(function (value, name) {
result += canonicalHeaderName(name) + ": " + value + "\r\n";
});
return result;
}
overrideMimeType() {
// Kept as a compatibility no-op until MIME decoding is configurable.
}
send(body) {
if (this.readyState !== XMLHttpRequest.OPENED || this._sendStarted) {
throw new DOMException("XMLHttpRequest is not opened", "InvalidStateError");
}
this._sendStarted = true;
this._aborted = false;
this._timedOut = false;
this._controller = new AbortController();
const uploadTotal = bodySize(body);
this._emit("loadstart", 0, -1);
this.upload._emit("loadstart", 0, uploadTotal);
if (this.timeout > 0) {
this._timeoutID = setTimeout(() => {
this._timedOut = true;
this._controller.abort();
}, this.timeout);
}
const options = {
method: this._method,
headers: this._requestHeaders,
redirect: this.redirect || "follow",
credentials: this.withCredentials ? "include" : "same-origin",
signal: this._controller.signal,
};
if (this._method !== "GET" && this._method !== "HEAD" && body != null) {
options.body = body;
}
Promise.resolve().then(() => globalThis.fetch(this._url, options)).then(async (response) => {
if (this._aborted || this._timedOut) return;
this.upload._emit("progress", uploadTotal, uploadTotal);
this.upload._emit("load", uploadTotal, uploadTotal);
this.upload._emit("loadend", uploadTotal, uploadTotal);
this.status = response.status;
this.statusText = response.statusText;
this.responseURL = response.url || this._url;
this._responseHeaders = response.headers;
this._setReadyState(XMLHttpRequest.HEADERS_RECEIVED);
const contentLengthValue = response.headers.get("content-length");
const contentLength = contentLengthValue == null ? -1 : Number(contentLengthValue);
const chunks = [];
let loaded = 0;
if (response.body) {
const reader = response.body.getReader();
try {
while (true) {
const item = await reader.read();
if (item.done) break;
const chunk = item.value instanceof Uint8Array ? item.value : new Uint8Array(item.value);
chunks.push(chunk);
loaded += chunk.byteLength;
this._setReadyState(XMLHttpRequest.LOADING);
this._emit("progress", loaded, contentLength);
}
} finally {
if (typeof reader.releaseLock === "function") reader.releaseLock();
}
}
const bytes = new Uint8Array(loaded);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
const text = new TextDecoder().decode(bytes);
switch (this.responseType) {
case "arraybuffer":
this.response = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
break;
case "blob":
this.response = new Blob([bytes], { type: response.headers.get("content-type") || "" });
break;
case "json":
try {
this.response = text === "" ? null : JSON.parse(text);
} catch (_) {
this.response = null;
}
break;
case "":
case "text":
default:
this.response = text;
this.responseText = text;
break;
}
this._clearTimeout();
this._setReadyState(XMLHttpRequest.DONE);
this._emit("load", loaded, contentLength);
this._emit("loadend", loaded, contentLength);
}).catch((error) => {
this._clearTimeout();
if (this._timedOut) {
this.status = 0;
this._setReadyState(XMLHttpRequest.DONE);
this.upload._emit("timeout", 0, uploadTotal);
this._emit("timeout", 0, -1);
this._emit("loadend", 0, -1);
return;
}
if (this._aborted || (error && error.name === "AbortError")) {
this.status = 0;
this._setReadyState(XMLHttpRequest.DONE);
this.upload._emit("abort", 0, uploadTotal);
this._emit("abort", 0, -1);
this._emit("loadend", 0, -1);
return;
}
this.status = 0;
this._setReadyState(XMLHttpRequest.DONE);
this.upload._emit("error", 0, uploadTotal);
this._emit("error", 0, -1);
this._emit("loadend", 0, -1);
});
}
abort() {
if (!this._sendStarted || this.readyState === XMLHttpRequest.DONE) return;
this._aborted = true;
this._clearTimeout();
if (this._controller) this._controller.abort();
}
_clearTimeout() {
if (this._timeoutID != null) {
clearTimeout(this._timeoutID);
this._timeoutID = null;
}
}
_setReadyState(state) {
this.readyState = state;
this._emit("readystatechange", 0, -1);
}
_emit(type, loaded, total) {
const event = createProgressEvent(type, loaded, total);
const handler = this["on" + type];
if (typeof handler === "function") handler.call(this, event);
this.dispatchEvent(event);
}
}
XMLHttpRequest.UNSENT = 0;
XMLHttpRequest.OPENED = 1;
XMLHttpRequest.HEADERS_RECEIVED = 2;
XMLHttpRequest.LOADING = 3;
XMLHttpRequest.DONE = 4;
XMLHttpRequest.prototype.UNSENT = 0;
XMLHttpRequest.prototype.OPENED = 1;
XMLHttpRequest.prototype.HEADERS_RECEIVED = 2;
XMLHttpRequest.prototype.LOADING = 3;
XMLHttpRequest.prototype.DONE = 4;
globalThis.XMLHttpRequest = XMLHttpRequest;
})();