4fa4f13558
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
309 lines
9.0 KiB
JavaScript
309 lines
9.0 KiB
JavaScript
const { WebSocket, WebSocketServer } = require("ws");
|
|
|
|
const buildErrorResponse = (id, code, message) => {
|
|
return {
|
|
type: "res",
|
|
id,
|
|
ok: false,
|
|
error: { code, message },
|
|
};
|
|
};
|
|
|
|
const isObject = (value) => Boolean(value && typeof value === "object");
|
|
|
|
const safeJsonParse = (raw) => {
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const resolvePathname = (url) => {
|
|
const raw = typeof url === "string" ? url : "";
|
|
const idx = raw.indexOf("?");
|
|
return (idx === -1 ? raw : raw.slice(0, idx)) || "/";
|
|
};
|
|
|
|
const injectAuthToken = (params, token) => {
|
|
const next = isObject(params) ? { ...params } : {};
|
|
const auth = isObject(next.auth) ? { ...next.auth } : {};
|
|
auth.token = token;
|
|
next.auth = auth;
|
|
return next;
|
|
};
|
|
|
|
const resolveOriginForUpstream = (upstreamUrl) => {
|
|
const url = new URL(upstreamUrl);
|
|
const proto = url.protocol === "wss:" ? "https:" : "http:";
|
|
const hostname =
|
|
url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "0.0.0.0"
|
|
? "localhost"
|
|
: url.hostname;
|
|
const host = url.port ? `${hostname}:${url.port}` : hostname;
|
|
return `${proto}//${host}`;
|
|
};
|
|
|
|
const hasNonEmptyToken = (params) => {
|
|
const raw = params && isObject(params) && isObject(params.auth) ? params.auth.token : "";
|
|
return typeof raw === "string" && raw.trim().length > 0;
|
|
};
|
|
|
|
const hasNonEmptyPassword = (params) => {
|
|
const raw = params && isObject(params) && isObject(params.auth) ? params.auth.password : "";
|
|
return typeof raw === "string" && raw.trim().length > 0;
|
|
};
|
|
|
|
const hasNonEmptyDeviceToken = (params) => {
|
|
const raw = params && isObject(params) && isObject(params.auth) ? params.auth.deviceToken : "";
|
|
return typeof raw === "string" && raw.trim().length > 0;
|
|
};
|
|
|
|
const hasCompleteDeviceAuth = (params) => {
|
|
const device = params && isObject(params) && isObject(params.device) ? params.device : null;
|
|
if (!device) {
|
|
return false;
|
|
}
|
|
const id = typeof device.id === "string" ? device.id.trim() : "";
|
|
const publicKey = typeof device.publicKey === "string" ? device.publicKey.trim() : "";
|
|
const signature = typeof device.signature === "string" ? device.signature.trim() : "";
|
|
const nonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
|
|
const signedAt = device.signedAt;
|
|
return (
|
|
id.length > 0 &&
|
|
publicKey.length > 0 &&
|
|
signature.length > 0 &&
|
|
nonce.length > 0 &&
|
|
Number.isFinite(signedAt) &&
|
|
signedAt >= 0
|
|
);
|
|
};
|
|
|
|
function createGatewayProxy(options) {
|
|
const {
|
|
loadUpstreamSettings,
|
|
allowWs = (req) => resolvePathname(req.url) === "/api/gateway/ws",
|
|
log = () => {},
|
|
logError = (msg, err) => console.error(msg, err),
|
|
} = options || {};
|
|
|
|
if (typeof loadUpstreamSettings !== "function") {
|
|
throw new Error("createGatewayProxy requires loadUpstreamSettings().");
|
|
}
|
|
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
|
|
wss.on("connection", (browserWs) => {
|
|
let upstreamWs = null;
|
|
let upstreamReady = false;
|
|
let upstreamUrl = "";
|
|
let upstreamToken = "";
|
|
let connectRequestId = null;
|
|
let connectResponseSent = false;
|
|
let pendingConnectFrame = null;
|
|
let pendingUpstreamSetupError = null;
|
|
let closed = false;
|
|
|
|
const closeBoth = (code, reason) => {
|
|
if (closed) return;
|
|
closed = true;
|
|
try {
|
|
browserWs.close(code, reason);
|
|
} catch {}
|
|
try {
|
|
upstreamWs?.close(code, reason);
|
|
} catch {}
|
|
};
|
|
|
|
const sendToBrowser = (frame) => {
|
|
if (browserWs.readyState !== WebSocket.OPEN) return;
|
|
browserWs.send(JSON.stringify(frame));
|
|
};
|
|
|
|
const sendConnectError = (code, message) => {
|
|
if (connectRequestId && !connectResponseSent) {
|
|
connectResponseSent = true;
|
|
sendToBrowser(buildErrorResponse(connectRequestId, code, message));
|
|
}
|
|
closeBoth(1011, "connect failed");
|
|
};
|
|
|
|
const forwardConnectFrame = (frame) => {
|
|
const browserHasAuth =
|
|
hasNonEmptyToken(frame.params) ||
|
|
hasNonEmptyPassword(frame.params) ||
|
|
hasNonEmptyDeviceToken(frame.params) ||
|
|
hasCompleteDeviceAuth(frame.params);
|
|
|
|
if (!upstreamToken && !browserHasAuth) {
|
|
sendConnectError(
|
|
"studio.gateway_token_missing",
|
|
"Upstream gateway token is not configured on the Studio host."
|
|
);
|
|
return;
|
|
}
|
|
|
|
const connectFrame = browserHasAuth
|
|
? frame
|
|
: {
|
|
...frame,
|
|
params: injectAuthToken(frame.params, upstreamToken),
|
|
};
|
|
upstreamWs.send(JSON.stringify(connectFrame));
|
|
};
|
|
|
|
const maybeForwardPendingConnect = () => {
|
|
if (!pendingConnectFrame || !upstreamReady || upstreamWs?.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
const frame = pendingConnectFrame;
|
|
pendingConnectFrame = null;
|
|
forwardConnectFrame(frame);
|
|
};
|
|
|
|
const startUpstream = async () => {
|
|
try {
|
|
const settings = await loadUpstreamSettings();
|
|
upstreamUrl = typeof settings?.url === "string" ? settings.url.trim() : "";
|
|
upstreamToken = typeof settings?.token === "string" ? settings.token.trim() : "";
|
|
} catch (err) {
|
|
logError("Failed to load upstream gateway settings.", err);
|
|
pendingUpstreamSetupError = {
|
|
code: "studio.settings_load_failed",
|
|
message: "Failed to load Studio gateway settings.",
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (!upstreamUrl) {
|
|
pendingUpstreamSetupError = {
|
|
code: "studio.gateway_url_missing",
|
|
message: "Upstream gateway URL is not configured on the Studio host.",
|
|
};
|
|
return;
|
|
}
|
|
|
|
let upstreamOrigin = "";
|
|
try {
|
|
upstreamOrigin = resolveOriginForUpstream(upstreamUrl);
|
|
} catch {
|
|
pendingUpstreamSetupError = {
|
|
code: "studio.gateway_url_invalid",
|
|
message: "Upstream gateway URL is invalid on the Studio host.",
|
|
};
|
|
return;
|
|
}
|
|
|
|
upstreamWs = new WebSocket(upstreamUrl, { origin: upstreamOrigin });
|
|
|
|
upstreamWs.on("open", () => {
|
|
upstreamReady = true;
|
|
maybeForwardPendingConnect();
|
|
});
|
|
|
|
upstreamWs.on("message", (upRaw) => {
|
|
const upParsed = safeJsonParse(String(upRaw ?? ""));
|
|
if (upParsed && isObject(upParsed) && upParsed.type === "res") {
|
|
const resId = typeof upParsed.id === "string" ? upParsed.id : "";
|
|
if (resId && connectRequestId && resId === connectRequestId) {
|
|
connectResponseSent = true;
|
|
}
|
|
}
|
|
if (browserWs.readyState === WebSocket.OPEN) {
|
|
browserWs.send(String(upRaw ?? ""));
|
|
}
|
|
});
|
|
|
|
upstreamWs.on("close", (ev) => {
|
|
const reason = typeof ev?.reason === "string" ? ev.reason : "";
|
|
if (!connectResponseSent && connectRequestId) {
|
|
sendToBrowser(
|
|
buildErrorResponse(
|
|
connectRequestId,
|
|
"studio.upstream_closed",
|
|
`Upstream gateway closed (${ev.code}): ${reason}`
|
|
)
|
|
);
|
|
}
|
|
closeBoth(1012, "upstream closed");
|
|
});
|
|
|
|
upstreamWs.on("error", (err) => {
|
|
logError("Upstream gateway WebSocket error.", err);
|
|
sendConnectError(
|
|
"studio.upstream_error",
|
|
"Failed to connect to upstream gateway WebSocket."
|
|
);
|
|
});
|
|
|
|
log("proxy connected");
|
|
};
|
|
|
|
void startUpstream();
|
|
|
|
browserWs.on("message", async (raw) => {
|
|
const parsed = safeJsonParse(String(raw ?? ""));
|
|
if (!parsed || !isObject(parsed)) {
|
|
closeBoth(1003, "invalid json");
|
|
return;
|
|
}
|
|
|
|
if (!connectRequestId) {
|
|
if (parsed.type !== "req" || parsed.method !== "connect") {
|
|
closeBoth(1008, "connect required");
|
|
return;
|
|
}
|
|
const id = typeof parsed.id === "string" ? parsed.id : "";
|
|
if (!id) {
|
|
closeBoth(1008, "connect id required");
|
|
return;
|
|
}
|
|
connectRequestId = id;
|
|
if (pendingUpstreamSetupError) {
|
|
sendConnectError(pendingUpstreamSetupError.code, pendingUpstreamSetupError.message);
|
|
return;
|
|
}
|
|
pendingConnectFrame = parsed;
|
|
maybeForwardPendingConnect();
|
|
return;
|
|
}
|
|
|
|
if (!upstreamReady || upstreamWs.readyState !== WebSocket.OPEN) {
|
|
closeBoth(1013, "upstream not ready");
|
|
return;
|
|
}
|
|
|
|
if (parsed.type === "req" && parsed.method === "connect" && !connectResponseSent) {
|
|
pendingConnectFrame = null;
|
|
forwardConnectFrame(parsed);
|
|
return;
|
|
}
|
|
|
|
upstreamWs.send(JSON.stringify(parsed));
|
|
});
|
|
|
|
browserWs.on("close", () => {
|
|
closeBoth(1000, "client closed");
|
|
});
|
|
|
|
browserWs.on("error", (err) => {
|
|
logError("Browser WebSocket error.", err);
|
|
closeBoth(1011, "client error");
|
|
});
|
|
});
|
|
|
|
const handleUpgrade = (req, socket, head) => {
|
|
if (!allowWs(req)) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
wss.emit("connection", ws, req);
|
|
});
|
|
};
|
|
|
|
return { wss, handleUpgrade };
|
|
}
|
|
|
|
module.exports = { createGatewayProxy };
|