mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 08:14:38 +08:00
Run extensions that carry cookies through an http.Agent (#711)
Hide My Email failed at load with "The superclass is not a constructor". It hands axios a cookie jar through axios-cookiejar-support, whose http-cookie-agent and agent-base extend http.Agent when the bundle loads. The http shim had no Agent, and esbuild's namespace interop copies only the Proxy target's own keys, so the superclass was undefined. http.Agent is now a real class whose addRequest does nothing, because the bridge owns every socket. ClientRequest calls it only for an http.Agent subclass, exposes Node's protocol/host/path, and runs _implicitHeader in end(), which is where the cookie agent sets Cookie. Any other agent shape is still ignored, as before. url.format now takes a parts object, which the cookie agent builds for each request. URLSession folds repeated Set-Cookie headers into one comma-joined line, so a jar kept only the first cookie and iCloud login could not finish. IncomingMessage splits it back into Node's array without cutting an Expires date, and rawHeaders repeats the name per cookie.
This commit is contained in:
@@ -382,6 +382,65 @@ export default async function Command() {
|
||||
}
|
||||
`;
|
||||
|
||||
// Hide My Email hands axios a cookie jar through axios-cookiejar-support, whose http-cookie-agent
|
||||
// extends `http.Agent` at load time and hooks each request in `addRequest` — the same way this does.
|
||||
const cookieAgentSource = `
|
||||
import * as http from "node:http";
|
||||
import * as url from "node:url";
|
||||
|
||||
class CookieAgent extends http.Agent {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.jar = new Map();
|
||||
}
|
||||
|
||||
addRequest(request, options) {
|
||||
const target = url.format({ host: request.host, pathname: request.path, protocol: request.protocol });
|
||||
const implicitHeader = request._implicitHeader.bind(request);
|
||||
request._implicitHeader = () => {
|
||||
if (this.jar.size) request.setHeader("Cookie", [...this.jar].map(([k, v]) => k + "=" + v).join("; "));
|
||||
implicitHeader();
|
||||
};
|
||||
const emit = request.emit.bind(request);
|
||||
request.emit = (event, ...args) => {
|
||||
if (event === "response") {
|
||||
for (const line of args[0].headers["set-cookie"] ?? []) {
|
||||
const [pair] = line.split(";");
|
||||
const [name, value] = pair.split("=");
|
||||
this.jar.set(name, value);
|
||||
}
|
||||
this.urls.push(target);
|
||||
}
|
||||
return emit(event, ...args);
|
||||
};
|
||||
super.addRequest(request, options);
|
||||
}
|
||||
}
|
||||
|
||||
const send = (agent, path) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const request = http.request("https://example.test" + path, { agent }, (response) => {
|
||||
response.resume();
|
||||
response.on("end", () => resolve(response));
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
|
||||
export default async function Command() {
|
||||
const agent = new CookieAgent({ keepAlive: true });
|
||||
agent.urls = [];
|
||||
const first = await send(agent, "/signin?step=1");
|
||||
await send(agent, "/account");
|
||||
globalThis.__cookieAgent = {
|
||||
isAgent: agent instanceof http.Agent,
|
||||
setCookie: first.headers["set-cookie"],
|
||||
rawHeaders: first.rawHeaders,
|
||||
urls: agent.urls,
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
// The Homebrew extension streams its package index to disk rather than buffering it: it guards on
|
||||
// `response.body`, counts bytes through a `TransformStream`, and pipes the result into a file — then
|
||||
// reads it back through a `Transform`. Issue #429: `Response` had no `body`, so it failed at "HTTP 200".
|
||||
@@ -812,6 +871,35 @@ export async function runFixtures() {
|
||||
},
|
||||
);
|
||||
|
||||
const cookieSpecs = [];
|
||||
const cookies = ["a=1; Expires=Wed, 21 Oct 2037 07:28:00 GMT; Path=/", "b=2; Path=/"];
|
||||
await run(
|
||||
"an http.Agent subclass carries cookies between requests",
|
||||
cookieAgentSource,
|
||||
"no-view",
|
||||
async (harness) => {
|
||||
const result = harness.call("globalThis.__cookieAgent");
|
||||
const setCookie = JSON.stringify(result?.setCookie);
|
||||
const rawHeaders = JSON.stringify(result?.rawHeaders);
|
||||
const urls = JSON.stringify(result?.urls);
|
||||
const sent = cookieSpecs[1]?.headers;
|
||||
check("http.Agent survives esbuild's namespace import", result?.isAgent === true, JSON.stringify(result));
|
||||
check("splits a folded Set-Cookie without cutting its Expires date", setCookie === JSON.stringify(cookies), setCookie);
|
||||
check("rawHeaders repeats the name per cookie", rawHeaders === JSON.stringify(cookies.flatMap((c) => ["set-cookie", c])), rawHeaders);
|
||||
check("url.format builds the request URL from its parts", result?.urls?.[0] === "https://example.test/signin%3Fstep=1", urls);
|
||||
check("the second request sends every cookie the first received", sent?.cookie === "a=1; b=2", JSON.stringify(sent));
|
||||
},
|
||||
{
|
||||
stubs: {
|
||||
"fetch.request": (args) => {
|
||||
cookieSpecs.push(args[0]);
|
||||
const headers = cookieSpecs.length === 1 ? { "set-cookie": cookies.join(", ") } : {};
|
||||
return { status: 200, statusText: "OK", headers, url: args[0].url, bodyBase64: "" };
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const indexBody = JSON.stringify(Array.from({ length: 4000 }, (_, index) => ({ name: `pkg-${index}` })));
|
||||
await run(
|
||||
"a fetch body streams through a transform onto disk",
|
||||
|
||||
@@ -1060,10 +1060,19 @@ class IncomingMessage extends PassThrough {
|
||||
([name]) => name !== "content-encoding" && name !== "content-length",
|
||||
),
|
||||
);
|
||||
this.rawHeaders = Object.entries(this.headers).flat();
|
||||
// URLSession folds repeated `Set-Cookie` headers into one line; Node always hands out an array.
|
||||
if (typeof this.headers["set-cookie"] === "string") {
|
||||
this.headers["set-cookie"] = this.headers["set-cookie"].split(SET_COOKIE_BOUNDARY);
|
||||
}
|
||||
this.rawHeaders = Object.entries(this.headers).flatMap(([name, value]) =>
|
||||
[value].flat().flatMap((item) => [name, item]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A comma that starts another `name=` — never the one inside an `Expires` date.
|
||||
const SET_COOKIE_BOUNDARY = /,\s*(?=[^;,=\s]+=)/;
|
||||
|
||||
const HEADER_TOKEN = /^[\^`\-\w!#$%&'*+.|~]+$/;
|
||||
const HEADER_VALUE = /[^\t\u0020-\u007e\u0080-\u00ff]/;
|
||||
|
||||
@@ -1088,10 +1097,29 @@ function validateHeaderValue(name, value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bridge owns every socket; `addRequest` is only a hook for cookie agents to override.
|
||||
class Agent extends EventEmitter {
|
||||
constructor(options) {
|
||||
super();
|
||||
this.options = { ...options };
|
||||
}
|
||||
|
||||
addRequest() {}
|
||||
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
class ClientRequest extends EventEmitter {
|
||||
constructor(url, options, callback) {
|
||||
super();
|
||||
this.url = url;
|
||||
// A malformed URL still fails the way it always has: as an `error` once the bridge rejects it.
|
||||
if (URL.canParse(url)) {
|
||||
const target = new URL(url);
|
||||
this.protocol = target.protocol;
|
||||
this.host = target.hostname;
|
||||
this.path = target.pathname + target.search;
|
||||
}
|
||||
this.method = String(options.method ?? "GET").toUpperCase();
|
||||
this.writable = true;
|
||||
this.writableEnded = false;
|
||||
@@ -1100,8 +1128,13 @@ class ClientRequest extends EventEmitter {
|
||||
this._destroyed = false;
|
||||
for (const [name, value] of Object.entries(options.headers ?? {})) this.setHeader(name, value);
|
||||
if (callback) this.once("response", callback);
|
||||
// Any other agent shape — agent-base 6 extends EventEmitter — would try to open a socket.
|
||||
if (options.agent instanceof Agent) options.agent.addRequest(this, options);
|
||||
}
|
||||
|
||||
/// Node's last chance to touch headers before they go out; cookie agents wrap it.
|
||||
_implicitHeader() {}
|
||||
|
||||
setHeader(name, value) {
|
||||
this._headers.set(String(name).toLowerCase(), Array.isArray(value) ? value.join(", ") : String(value));
|
||||
return this;
|
||||
@@ -1126,6 +1159,7 @@ class ClientRequest extends EventEmitter {
|
||||
|
||||
end(chunk) {
|
||||
if (chunk !== undefined && chunk !== null) this.write(chunk);
|
||||
this._implicitHeader();
|
||||
this.writableEnded = true;
|
||||
this._send();
|
||||
return this;
|
||||
@@ -1369,6 +1403,19 @@ const querystring = {
|
||||
unescape: decodeURIComponent,
|
||||
};
|
||||
|
||||
/// Node's legacy `url.format`, which also takes the parts object http-cookie-agent builds per request.
|
||||
function formatURL(value) {
|
||||
if (typeof value !== "object" || value === null || value instanceof URL) return String(value);
|
||||
const protocol = value.protocol ? value.protocol.replace(/:?$/, ":") : "";
|
||||
const slashes = value.slashes || /^(https?|ftp|gopher|file|wss?):$/.test(protocol) ? "//" : "";
|
||||
const auth = value.auth ? `${value.auth}@` : "";
|
||||
const host = value.host ?? (value.hostname ? value.hostname + (value.port ? `:${value.port}` : "") : "");
|
||||
const pathname = (value.pathname ?? "").replace(/[?#]/g, encodeURIComponent);
|
||||
const query = value.query && typeof value.query === "object" ? querystring.stringify(value.query) : "";
|
||||
const search = value.search ?? (query ? `?${query}` : "");
|
||||
return `${protocol}${slashes}${auth}${host}${pathname}${search}${value.hash ?? ""}`;
|
||||
}
|
||||
|
||||
function assert(value, message) {
|
||||
if (!value) throw new Error(message || "Assertion failed");
|
||||
}
|
||||
@@ -1445,7 +1492,8 @@ const httpLike = (name) =>
|
||||
validateHeaderValue,
|
||||
IncomingMessage,
|
||||
ClientRequest,
|
||||
globalAgent: {},
|
||||
Agent,
|
||||
globalAgent: new Agent(),
|
||||
STATUS_CODES: {},
|
||||
METHODS: [],
|
||||
});
|
||||
@@ -1499,7 +1547,7 @@ export const nodeModules = {
|
||||
assert,
|
||||
string_decoder: { StringDecoder },
|
||||
// node-fetch spreads a parsed URL into its request options and reads the legacy `path` off it.
|
||||
url: { URL, URLSearchParams, fileURLToPath, pathToFileURL, parse: (text) => Object.assign(new URL(text), { path: new URL(text).pathname + new URL(text).search }), format: (value) => String(value), resolve: (from, to) => new URL(to, from).href },
|
||||
url: { URL, URLSearchParams, fileURLToPath, pathToFileURL, parse: (text) => Object.assign(new URL(text), { path: new URL(text).pathname + new URL(text).search }), format: formatURL, resolve: (from, to) => new URL(to, from).href },
|
||||
timers: { setTimeout, clearTimeout, setInterval, clearInterval, setImmediate, clearImmediate },
|
||||
"timers/promises": { setTimeout: (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms)) },
|
||||
perf_hooks: { performance: globalThis.performance },
|
||||
|
||||
Reference in New Issue
Block a user