Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
142 lines
No EOL
7.5 KiB
JavaScript
142 lines
No EOL
7.5 KiB
JavaScript
import { HostBudgets } from "./budget.js";
|
|
import { EgressError } from "./errors.js";
|
|
import { EgressPolicy } from "./policy.js";
|
|
import { requestPinned } from "./transport.js";
|
|
const redirectStatuses = new Set([301, 302, 303, 307, 308]);
|
|
export function createEgressClient(options) {
|
|
const policy = new EgressPolicy(options);
|
|
const budgets = new HostBudgets(options);
|
|
const timeoutMs = options.timeoutMs ?? 15_000;
|
|
const maxResponseBytes = options.maxResponseBytes ?? 8 * 1024 * 1024;
|
|
const maxRequestBytes = options.maxRequestBytes ?? 8 * 1024 * 1024;
|
|
const maxRedirects = options.maxRedirects ?? 5;
|
|
if (![timeoutMs, maxResponseBytes, maxRequestBytes].every((value) => Number.isSafeInteger(value) && value > 0) || !Number.isSafeInteger(maxRedirects) || maxRedirects < 0) {
|
|
throw new EgressError("INVALID_CONFIGURATION", "Invalid request limits");
|
|
}
|
|
async function egressFetch(raw, init = {}, context) {
|
|
const controller = new AbortController();
|
|
const abort = () => controller.abort(new EgressError("ABORTED", "Request aborted"));
|
|
init.signal?.addEventListener("abort", abort, { once: true });
|
|
if (init.signal?.aborted)
|
|
abort();
|
|
const timer = setTimeout(() => controller.abort(new EgressError("TIMEOUT", "Egress deadline exceeded")), timeoutMs);
|
|
const check = () => { if (controller.signal.aborted)
|
|
throw controller.signal.reason; };
|
|
let abortListener;
|
|
const cancelled = new Promise((_resolve, reject) => {
|
|
abortListener = () => reject(controller.signal.reason);
|
|
controller.signal.addEventListener("abort", abortListener, { once: true });
|
|
if (controller.signal.aborted)
|
|
abortListener();
|
|
});
|
|
const run = async () => {
|
|
check();
|
|
let url = policy.validateUrl(raw);
|
|
const pinnedOrigin = context.pinnedOrigin === true ? url.origin : context.pinnedOrigin ? policy.validateUrl(context.pinnedOrigin).origin : undefined;
|
|
if (pinnedOrigin && url.origin !== pinnedOrigin)
|
|
throw new EgressError("ORIGIN_DENIED", "Request is outside the pinned origin");
|
|
// Request provides fetch method/header/body normalization; the transport owns connection options.
|
|
let normalized;
|
|
try {
|
|
normalized = new Request(url, init);
|
|
}
|
|
catch {
|
|
throw new EgressError("INVALID_REQUEST", "Invalid fetch request initialization");
|
|
}
|
|
let method = normalized.method;
|
|
const headers = new Headers(normalized.headers);
|
|
let body;
|
|
if (normalized.body) {
|
|
const reader = normalized.body.getReader();
|
|
const cancelBody = () => { void reader.cancel().catch(() => undefined); };
|
|
controller.signal.addEventListener("abort", cancelBody, { once: true });
|
|
const chunks = [];
|
|
let size = 0;
|
|
try {
|
|
for (;;) {
|
|
check();
|
|
const { value, done } = await reader.read();
|
|
if (done)
|
|
break;
|
|
size += value.length;
|
|
if (size > maxRequestBytes)
|
|
throw new EgressError("REQUEST_TOO_LARGE", "Request exceeds size limit");
|
|
chunks.push(Buffer.from(value));
|
|
}
|
|
body = Buffer.concat(chunks);
|
|
}
|
|
finally {
|
|
controller.signal.removeEventListener("abort", cancelBody);
|
|
void reader.cancel().catch(() => undefined);
|
|
}
|
|
}
|
|
const visited = new Set();
|
|
for (let hop = 0;; hop++) {
|
|
check();
|
|
if (visited.has(url.href))
|
|
throw new EgressError("REDIRECT_LOOP", "Redirect cycle detected");
|
|
visited.add(url.href);
|
|
const endpoint = await policy.validate(url);
|
|
check();
|
|
await budgets.consume(endpoint.hostKey, context.hostClass);
|
|
check();
|
|
const wire = await requestPinned(endpoint, method, headers, body, controller.signal, maxResponseBytes, (status, responseHeaders) => budgets.observe(endpoint.hostKey, context.hostClass, status, responseHeaders));
|
|
check();
|
|
const location = wire.headers.get("location");
|
|
if (!redirectStatuses.has(wire.status) || !location || init.redirect === "manual") {
|
|
const response = new Response(method === "HEAD" || [204, 205, 304].includes(wire.status) ? null : new Uint8Array(wire.body), { status: wire.status, statusText: wire.statusText, headers: wire.headers });
|
|
Object.defineProperties(response, { url: { value: url.href }, redirected: { value: hop > 0 } });
|
|
return response;
|
|
}
|
|
if (init.redirect === "error" || hop >= maxRedirects)
|
|
throw new EgressError("REDIRECT_LOOP", "Redirect limit exceeded");
|
|
if (/[\s\\\u0000-\u001f\u007f]/u.test(location))
|
|
throw new EgressError("INVALID_URL", "Malformed redirect target");
|
|
let target;
|
|
try {
|
|
target = policy.validateUrl(new URL(location, url));
|
|
}
|
|
catch (error) {
|
|
if (error instanceof EgressError)
|
|
throw error;
|
|
throw new EgressError("INVALID_URL", "Invalid redirect target");
|
|
}
|
|
if (pinnedOrigin && target.origin !== pinnedOrigin)
|
|
throw new EgressError("ORIGIN_DENIED", "Redirect is outside the pinned origin");
|
|
if (target.origin !== url.origin) {
|
|
for (const name of ["authorization", "proxy-authorization", "cookie", "cookie2"])
|
|
headers.delete(name);
|
|
// A body can contain secrets just as headers can; refuse to replay it off-origin.
|
|
if (body && (wire.status === 307 || wire.status === 308 || method !== "POST" && wire.status !== 303)) {
|
|
throw new EgressError("ORIGIN_DENIED", "Request body cannot be redirected off-origin");
|
|
}
|
|
}
|
|
if (wire.status === 303 && method !== "HEAD" || (wire.status === 301 || wire.status === 302) && method === "POST") {
|
|
method = "GET";
|
|
body = undefined;
|
|
for (const name of ["content-type", "content-length", "content-encoding", "content-language", "content-location"])
|
|
headers.delete(name);
|
|
}
|
|
url = target;
|
|
}
|
|
};
|
|
try {
|
|
return await Promise.race([run(), cancelled]);
|
|
}
|
|
catch (error) {
|
|
throw error instanceof EgressError ? error : new EgressError("NETWORK_ERROR", "Egress request failed");
|
|
}
|
|
finally {
|
|
clearTimeout(timer);
|
|
init.signal?.removeEventListener("abort", abort);
|
|
if (abortListener)
|
|
controller.signal.removeEventListener("abort", abortListener);
|
|
}
|
|
}
|
|
return { egressFetch, policy, budgets };
|
|
}
|
|
/** Stateless convenience form. Reuse createEgressClient when pool/config are fixed. */
|
|
export function egressFetch(url, init = {}, options) {
|
|
return createEgressClient(options).egressFetch(url, init, options);
|
|
}
|
|
//# sourceMappingURL=client.js.map
|