smee-client
Version:
Client to proxy webhooks to localhost
245 lines • 8.58 kB
JavaScript
import { fetch as undiciFetch, EnvHttpProxyAgent } from "undici";
import { EventSource, } from "eventsource";
const proxyAgent = new EnvHttpProxyAgent();
const trimTrailingSlash = (url) => {
return url.lastIndexOf("/") === url.length - 1 ? url.slice(0, -1) : url;
};
function validateURL(urlString) {
if (URL.canParse(urlString) === false) {
throw new Error(`The provided URL is invalid.`);
}
const url = new URL(urlString);
if (!url.protocol || !["http:", "https:"].includes(url.protocol)) {
throw new Error(`The provided URL is invalid.`);
}
if (!url.host) {
throw new Error(`The provided URL is invalid.`);
}
}
class SmeeClient {
#source;
#target;
#fetch;
#logger;
#events = null;
#queryForwarding = true;
#maxConnectionTimeout;
#forward = undefined;
#onerror = (err) => {
if (this.#events?.readyState === EventSource.CLOSED) {
this.#logger.error("Connection closed");
}
else {
this.#logger.error("Error in connection", err);
}
};
#onopen = () => { };
#onmessage = async (msg) => {
if (!this.#forward) {
return;
}
const data = JSON.parse(msg.data);
const target = new URL(this.#target);
if (this.#queryForwarding && data.query) {
Object.keys(data.query).forEach((key) => {
target.searchParams.set(key, data.query[key]);
});
target.search = target.searchParams.toString();
}
delete data.query;
const body = JSON.stringify(data.body);
delete data.body;
const headers = {};
Object.keys(data).forEach((key) => {
headers[key] = data[key];
});
// Don't forward the host header. As it causes issues with some servers
// See https://github.com/probot/smee-client/issues/295
// See https://github.com/probot/smee-client/issues/187
delete headers["host"];
headers["content-length"] = Buffer.byteLength(body);
headers["content-type"] = "application/json";
try {
const response = await this.#fetch(target, {
method: "POST",
mode: data["sec-fetch-mode"],
body,
headers,
dispatcher: proxyAgent,
});
this.#logger.info(`POST ${response.url} - ${response.status}`);
}
catch (err) {
this.#logger.error(err);
}
};
#events_onopen = null;
#events_onmessage = null;
#events_onerror = null;
constructor({ source, target, logger = console, fetch = undiciFetch, maxConnectionTimeout, queryForwarding = true, forward, }) {
validateURL(target);
validateURL(source);
this.#source = trimTrailingSlash(new URL(source).toString());
this.#target = trimTrailingSlash(new URL(target).toString());
this.#logger = logger;
this.#fetch = fetch;
this.#queryForwarding = queryForwarding;
this.#maxConnectionTimeout = maxConnectionTimeout;
this.#forward = forward;
}
static async createChannel({ fetch = undiciFetch, newChannelUrl = "https://smee.io/new", } = {}) {
const response = await fetch(newChannelUrl, {
method: "HEAD",
redirect: "manual",
dispatcher: proxyAgent,
});
const address = response.headers.get("location");
if (!address) {
throw new Error("Failed to create channel");
}
return address;
}
get onmessage() {
if (this.#events === null) {
return this.#events_onmessage;
}
return this.#events.onmessage;
}
set onmessage(fn) {
if (typeof fn !== "function" && fn !== null) {
throw new TypeError("onmessage must be a function or null");
}
if (this.#events === null) {
this.#events_onmessage = fn;
return;
}
this.#events.onmessage = fn;
}
get onerror() {
if (this.#events === null) {
return this.#events_onerror;
}
return this.#events.onerror;
}
set onerror(fn) {
if (typeof fn !== "function" && fn !== null) {
throw new TypeError("onerror must be a function or null");
}
if (this.#events === null) {
this.#events_onerror = fn;
return;
}
this.#events.onerror = fn;
}
get onopen() {
if (this.#events === null) {
return this.#events_onopen;
}
return this.#events.onopen;
}
set onopen(fn) {
if (typeof fn !== "function" && fn !== null) {
throw new TypeError("onopen must be a function or null");
}
if (this.#events === null) {
this.#events_onopen = fn;
return;
}
this.#events.onopen = fn;
}
async start() {
const customFetch = (url, options) => {
return this.#fetch(url, {
...options,
dispatcher: proxyAgent,
});
};
const events = new EventSource(this.#source, {
fetch: customFetch,
});
// Reconnect immediately
events.reconnectInterval = 0; // This isn't a valid property of EventSource
const establishConnection = new Promise((resolve, reject) => {
events.addEventListener("open", () => {
this.#logger.info(`Connected to ${this.#source}`);
events.removeEventListener("error", reject);
if (this.#forward !== false) {
this.#startForwarding();
}
resolve();
});
events.addEventListener("error", reject);
});
this.#events = events;
events.addEventListener("message", this.#onmessage.bind(this));
events.addEventListener("open", this.#onopen.bind(this));
events.addEventListener("error", this.#onerror.bind(this));
if (this.#events_onmessage) {
events.onmessage = this.#events_onmessage;
}
if (this.#events_onopen) {
events.onopen = this.#events_onopen;
}
if (this.#events_onerror) {
events.onerror = this.#events_onerror;
}
if (this.#maxConnectionTimeout !== undefined) {
const timeoutConnection = new Promise((_, reject) => {
setTimeout(async () => {
if (events.readyState === EventSource.OPEN) {
// If the connection is already open, we don't need to reject
return;
}
this.#logger.error(`Connection to ${this.#source} timed out after ${this.#maxConnectionTimeout}ms`);
reject(new Error(`Connection to ${this.#source} timed out after ${this.#maxConnectionTimeout}ms`));
await this.stop();
}, this.#maxConnectionTimeout)?.unref();
});
await Promise.race([establishConnection, timeoutConnection]);
}
else {
await establishConnection;
}
return events;
}
async stop() {
if (this.#events) {
this.#stopForwarding();
this.#events.close();
this.#events = null;
this.#forward = undefined;
this.#logger.info("Connection closed");
}
}
#startForwarding() {
if (this.#forward === true) {
return;
}
this.#forward = true;
this.#logger.info(`Forwarding ${this.#source} to ${this.#target}`);
}
startForwarding() {
if (this.#forward === true) {
this.#logger.info(`Forwarding ${this.#source} to ${this.#target} is already enabled`);
return;
}
this.#startForwarding();
}
#stopForwarding() {
if (this.#forward !== true) {
return;
}
this.#forward = false;
this.#logger.info(`Stopped forwarding ${this.#source} to ${this.#target}`);
}
stopForwarding() {
if (this.#forward !== true) {
this.#logger.info(`Forwarding ${this.#source} to ${this.#target} is already disabled`);
return;
}
this.#stopForwarding();
}
}
export { SmeeClient as default, SmeeClient as "module.exports", // For require(esm) compatibility
SmeeClient, };
//# sourceMappingURL=index.js.map