First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user