h3
Version:
Minimal H(TTP) framework built for high performance and portability.
255 lines (254 loc) • 9.98 kB
JavaScript
import { EmptyObject, HTTPError, HTTPResponse } from "./response.mjs";
const ignoredHeaders = /* @__PURE__ */ new Set([
"transfer-encoding",
"accept-encoding",
"connection",
"keep-alive",
"upgrade",
"expect",
"te",
"trailer",
"host",
"proxy-authorization",
"proxy-connection"
]);
const framingHeaders = /* @__PURE__ */ new Set([
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"proxy-authorization",
"proxy-connection"
]);
function connectionTokens(connection) {
return new Set((connection || "").toLowerCase().split(",").map((name) => name.trim()).filter(Boolean));
}
const ignoredResponseHeaders = /* @__PURE__ */ new Set([
"content-encoding",
"content-length",
"transfer-encoding",
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-connection",
"upgrade",
"trailer",
"te"
]);
function rewriteCookieProperty(header, map, property) {
const _map = typeof map === "string" ? { "*": map } : map;
return header.replace(new RegExp(`(;\\s*${property}=)([^;]+)`, "gi"), (match, prefix, previousValue) => {
let newValue;
if (Object.hasOwn(_map, previousValue)) newValue = _map[previousValue];
else if (Object.hasOwn(_map, "*")) newValue = _map["*"];
else return match;
return newValue ? prefix + newValue : "";
});
}
function applyXForwardedHeaders(headers, event) {
const merged = headers instanceof Headers ? headers : new Headers(headers);
const ip = event.req.ip;
if (ip) {
const forwardedFor = merged.get("x-forwarded-for");
merged.set("x-forwarded-for", forwardedFor ? `${forwardedFor}, ${ip}` : ip);
}
const proto = event.url.protocol.slice(0, -1);
merged.set("x-forwarded-proto", proto);
merged.set("x-forwarded-host", event.url.host);
merged.set("x-forwarded-port", event.url.port || (proto === "https" ? "443" : "80"));
return merged;
}
function rewriteLocationHeaders(headers, rewrite, targetOrigin, requestOrigin) {
const rewriteValue = (value) => rewrite === true ? rewriteOrigin(value, targetOrigin, requestOrigin) : rewritePrefix(value, rewrite);
const location = headers.get("location");
if (location) {
const rewritten = rewriteValue(location);
if (rewritten) headers.set("location", rewritten);
}
const refresh = headers.get("refresh");
if (refresh) {
const match = refresh.match(/^(\s*(?:[\d.]+\s*[;,]\s*)?url\s*=\s*)(['"]?)(.*?)\2(\s*)$/i);
const rewritten = match && rewriteValue(match[3]);
if (rewritten) headers.set("refresh", match[1] + match[2] + rewritten + match[2] + match[4]);
}
}
function rewriteOrigin(value, targetOrigin, requestOrigin) {
if (!targetOrigin || targetOrigin === requestOrigin) return;
const url = value.startsWith("//") ? URL.canParse(value, targetOrigin) ? new URL(value, targetOrigin) : void 0 : URL.canParse(value) ? new URL(value) : void 0;
if (!url || url.origin !== targetOrigin) return;
return requestOrigin + url.pathname + url.search + url.hash;
}
function rewritePrefix(value, map) {
for (const prefix of Object.keys(map)) if (value.startsWith(prefix)) return map[prefix] + value.slice(prefix.length);
}
function abortable(run, signal) {
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
Promise.resolve(run()).then((value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
}, (error) => {
signal.removeEventListener("abort", onAbort);
reject(error);
});
});
}
function mergeHeaders(defaults, ...inputs) {
const _inputs = inputs.filter(Boolean);
if (_inputs.length === 0) return defaults;
const merged = new Headers(defaults);
for (const input of _inputs) {
const entries = Array.isArray(input) ? input : typeof input.entries === "function" ? input.entries() : Object.entries(input);
for (const [key, value] of entries) if (value !== void 0) merged.set(key, value);
}
return merged;
}
async function proxyRequest(event, target, opts = {}) {
const method = opts.fetchOptions?.method || event.req.method;
const methodUpper = method.toUpperCase();
const incomingBody = event.req.body;
const requestBody = incomingBody != null && methodUpper !== "GET" && methodUpper !== "HEAD" ? incomingBody : void 0;
const proxyHeaders = getProxyRequestHeaders(event, {
host: target.startsWith("/"),
forwardHeaders: opts.forwardHeaders,
filterHeaders: opts.filterHeaders
});
const fetchHeaders = mergeHeaders(opts.xfwd ? applyXForwardedHeaders(proxyHeaders, event) : proxyHeaders, opts.fetchOptions?.headers, opts.headers);
if (opts.fetchOptions && "body" in opts.fetchOptions || incomingBody && !requestBody) {
if (fetchHeaders instanceof Headers) fetchHeaders.delete("content-length");
else if (!Array.isArray(fetchHeaders)) delete fetchHeaders["content-length"];
}
const fetchBody = opts.fetchOptions?.body ?? requestBody;
return proxy(event, target, {
...opts,
fetchOptions: {
method,
body: requestBody,
...opts.fetchOptions,
duplex: opts.fetchOptions?.duplex ?? (fetchBody != null ? "half" : void 0),
headers: fetchHeaders
}
});
}
async function proxy(event, target, opts = {}) {
const signals = [event.req.signal];
if (opts.fetchOptions?.signal) signals.push(opts.fetchOptions.signal);
let timeoutId;
if (opts.timeout > 0 && Number.isFinite(opts.timeout)) {
const timeoutController = new AbortController();
timeoutId = setTimeout(() => timeoutController.abort(new DOMException("Proxy request timed out", "TimeoutError")), Math.min(Math.max(Math.trunc(opts.timeout), 1), 2147483647));
signals.push(timeoutController.signal);
}
const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
const fetchOptions = {
headers: opts.headers,
...opts.fetchOptions,
redirect: opts.fetchOptions?.redirect ?? "manual",
signal
};
let response;
try {
response = target[0] === "/" ? await abortable(() => event.app.fetch(createSubRequest(event, target, fetchOptions)), signal) : await fetch(target, fetchOptions);
} catch (error) {
if ((signal.aborted ? signal.reason : void 0)?.name === "TimeoutError" || error?.name === "TimeoutError") throw new HTTPError({
status: 504,
statusText: "Gateway Timeout",
cause: error
});
if (signal.aborted || error?.name === "AbortError") {
if (opts.propagateAbortError) throw error;
if (event.req.signal.aborted) return new HTTPResponse(null, {
status: 499,
statusText: "Client Closed Request"
});
}
throw new HTTPError("Bad Gateway", {
status: 502,
statusText: "Bad Gateway",
cause: error
});
} finally {
if (timeoutId !== void 0) clearTimeout(timeoutId);
}
if (response.type === "opaqueredirect") throw new HTTPError({
status: 502,
message: "Cannot relay an opaque redirect response on this runtime. Set `fetchOptions: { redirect: \"follow\" }` to follow upstream redirects instead."
});
if (response.type === "opaque" || response.type === "error" || response.status === 0) throw new HTTPError({
status: 502,
message: "Cannot relay an opaque or errored upstream response (status 0), typically caused by a `no-cors` request mode on browser/service-worker runtimes."
});
const headers = new Headers();
const connectionNominated = connectionTokens(response.headers.get("connection"));
for (const [key, value] of response.headers.entries()) {
if (ignoredResponseHeaders.has(key) || connectionNominated.has(key) || key === "set-cookie") continue;
headers.append(key, value);
}
const cookies = response.headers.getSetCookie();
if (cookies.length > 0) {
const _cookies = cookies.map((cookie) => {
if (opts.cookieDomainRewrite) cookie = rewriteCookieProperty(cookie, opts.cookieDomainRewrite, "domain");
if (opts.cookiePathRewrite) cookie = rewriteCookieProperty(cookie, opts.cookiePathRewrite, "path");
return cookie;
});
for (const cookie of _cookies) headers.append("set-cookie", cookie);
}
const locationRewrite = opts.locationRewrite ?? true;
if (locationRewrite !== false && (locationRewrite !== true || target[0] !== "/")) rewriteLocationHeaders(headers, locationRewrite, target[0] === "/" ? void 0 : new URL(target).origin, event.url.origin);
if (opts.onResponse) await opts.onResponse(event, response);
return new HTTPResponse(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
}
function getProxyRequestHeaders(event, opts) {
const headers = new EmptyObject();
const filterHeaders = opts?.filterHeaders?.map((h) => h.toLowerCase());
const forwardHeaders = opts?.forwardHeaders?.map((h) => h.toLowerCase());
const connectionNominated = connectionTokens(event.req.headers.get("connection"));
for (const [name, value] of event.req.headers.entries()) {
if (filterHeaders?.includes(name)) continue;
if (forwardHeaders?.includes(name) && !framingHeaders.has(name) && !connectionNominated.has(name)) {
headers[name] = value;
continue;
}
if (connectionNominated.has(name)) continue;
if (!ignoredHeaders.has(name) || name === "host" && opts?.host) {
headers[name] = value;
continue;
}
}
return headers;
}
async function fetchWithEvent(event, url, init) {
if (url[0] !== "/") {
if (init?.body != null && init.duplex === void 0) init = {
...init,
duplex: "half"
};
return fetch(url, init);
}
return event.app.fetch(createSubRequest(event, url, {
...init,
headers: mergeHeaders(getProxyRequestHeaders(event, { host: true }), init?.headers)
}));
}
function createSubRequest(event, path, init) {
const url = new URL(path.replace(LEADING_SEPARATOR_RUN_RE, "/"), event.url);
if (init.body != null && init.duplex === void 0) init = {
...init,
duplex: "half"
};
const req = new Request(url, init);
req.runtime = event.req.runtime;
req.waitUntil = event.req.waitUntil;
req.ip = event.req.ip;
return req;
}
const LEADING_SEPARATOR_RUN_RE = /^(?:[/\\]|[\t\n\r])+/;
export { fetchWithEvent, getProxyRequestHeaders, proxy, proxyRequest };