672 lines
18 KiB
JavaScript
672 lines
18 KiB
JavaScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { STATUS_CODES } from "node:http";
|
|
|
|
export const WS_PUBLIC_PATHS = new Set(["/v1/ws", "/api/v1/ws"]);
|
|
export const WS_ALLOWED_ENDPOINTS = new Set([
|
|
"/v1/chat/completions",
|
|
"/api/v1/chat/completions",
|
|
"/v1/messages",
|
|
"/api/v1/messages",
|
|
"/v1/responses",
|
|
"/api/v1/responses",
|
|
"/v1/completions",
|
|
"/api/v1/completions",
|
|
]);
|
|
|
|
const HANDSHAKE_PATH = "/api/v1/ws";
|
|
const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
const WS_QUERY_TOKEN_KEYS = ["api_key", "token", "access_token"];
|
|
const textEncoder = new TextEncoder();
|
|
const textDecoder = new TextDecoder();
|
|
|
|
function isText(value) {
|
|
return typeof value === "string" && value.length > 0;
|
|
}
|
|
|
|
function jsonStringifySafe(value) {
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return JSON.stringify({
|
|
type: "protocol.error",
|
|
code: "serialization_failed",
|
|
message: "Failed to serialize WebSocket payload",
|
|
});
|
|
}
|
|
}
|
|
|
|
function encodeWsFrame(opcode, payload = Buffer.alloc(0)) {
|
|
const payloadBuffer = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
const length = payloadBuffer.length;
|
|
|
|
let header;
|
|
if (length < 126) {
|
|
header = Buffer.allocUnsafe(2);
|
|
header[1] = length;
|
|
} else if (length <= 0xffff) {
|
|
header = Buffer.allocUnsafe(4);
|
|
header[1] = 126;
|
|
header.writeUInt16BE(length, 2);
|
|
} else {
|
|
header = Buffer.allocUnsafe(10);
|
|
header[1] = 127;
|
|
header.writeBigUInt64BE(BigInt(length), 2);
|
|
}
|
|
|
|
header[0] = 0x80 | (opcode & 0x0f);
|
|
return Buffer.concat([header, payloadBuffer]);
|
|
}
|
|
|
|
function decodeClientFrames(buffer) {
|
|
const frames = [];
|
|
let offset = 0;
|
|
|
|
while (buffer.length - offset >= 2) {
|
|
const byte1 = buffer[offset];
|
|
const byte2 = buffer[offset + 1];
|
|
const fin = (byte1 & 0x80) !== 0;
|
|
const opcode = byte1 & 0x0f;
|
|
const masked = (byte2 & 0x80) !== 0;
|
|
let payloadLength = byte2 & 0x7f;
|
|
let headerLength = 2;
|
|
|
|
if (!masked) {
|
|
throw new Error("Client WebSocket frames must be masked");
|
|
}
|
|
|
|
if (payloadLength === 126) {
|
|
if (buffer.length - offset < 4) break;
|
|
payloadLength = buffer.readUInt16BE(offset + 2);
|
|
headerLength = 4;
|
|
} else if (payloadLength === 127) {
|
|
if (buffer.length - offset < 10) break;
|
|
const bigLength = buffer.readBigUInt64BE(offset + 2);
|
|
if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
throw new Error("WebSocket payload too large");
|
|
}
|
|
payloadLength = Number(bigLength);
|
|
headerLength = 10;
|
|
}
|
|
|
|
const totalLength = headerLength + 4 + payloadLength;
|
|
if (buffer.length - offset < totalLength) break;
|
|
|
|
const mask = buffer.subarray(offset + headerLength, offset + headerLength + 4);
|
|
const payload = Buffer.from(buffer.subarray(offset + headerLength + 4, offset + totalLength));
|
|
for (let index = 0; index < payload.length; index += 1) {
|
|
payload[index] ^= mask[index % 4];
|
|
}
|
|
|
|
frames.push({ fin, opcode, payload });
|
|
offset += totalLength;
|
|
}
|
|
|
|
return {
|
|
frames,
|
|
remaining: buffer.subarray(offset),
|
|
};
|
|
}
|
|
|
|
function writeHttpError(socket, status, body, headers = {}) {
|
|
if (!socket.writable || socket.destroyed) return;
|
|
|
|
const bodyBuffer = Buffer.from(body || "", "utf8");
|
|
const statusText = STATUS_CODES[status] || "Error";
|
|
const responseHeaders = {
|
|
Connection: "close",
|
|
"Content-Length": String(bodyBuffer.length),
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
...headers,
|
|
};
|
|
|
|
const head = [
|
|
`HTTP/1.1 ${status} ${statusText}`,
|
|
...Object.entries(responseHeaders).map(([name, value]) => `${name}: ${value}`),
|
|
"",
|
|
"",
|
|
].join("\r\n");
|
|
|
|
socket.write(head);
|
|
socket.end(bodyBuffer);
|
|
}
|
|
|
|
function isWsPath(pathname) {
|
|
return WS_PUBLIC_PATHS.has(pathname);
|
|
}
|
|
|
|
function normalizeEndpoint(rawEndpoint) {
|
|
const endpoint = isText(rawEndpoint) ? rawEndpoint : "/v1/chat/completions";
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(endpoint, "http://omniroute.local");
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
if (parsed.origin !== "http://omniroute.local") {
|
|
return null;
|
|
}
|
|
|
|
if (!WS_ALLOWED_ENDPOINTS.has(parsed.pathname)) {
|
|
return null;
|
|
}
|
|
|
|
return `${parsed.pathname}${parsed.search}`;
|
|
}
|
|
|
|
function getForwardHeaders(requestUrl, requestHeaders) {
|
|
const headers = {
|
|
accept: "text/event-stream",
|
|
"content-type": "application/json",
|
|
};
|
|
|
|
const authorization = requestHeaders.authorization;
|
|
if (isText(authorization)) {
|
|
headers.authorization = authorization;
|
|
} else {
|
|
const url = new URL(requestUrl, "http://omniroute.local");
|
|
for (const key of WS_QUERY_TOKEN_KEYS) {
|
|
const value = url.searchParams.get(key);
|
|
if (isText(value)) {
|
|
headers.authorization = `Bearer ${value.trim()}`;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const cookie = requestHeaders.cookie;
|
|
if (isText(cookie)) {
|
|
headers.cookie = cookie;
|
|
}
|
|
|
|
const origin = requestHeaders.origin;
|
|
if (isText(origin)) {
|
|
headers.origin = origin;
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
async function performHandshake(fetchImpl, baseUrl, requestUrl, requestHeaders) {
|
|
const incomingUrl = new URL(requestUrl, baseUrl);
|
|
const handshakeUrl = new URL(HANDSHAKE_PATH, baseUrl);
|
|
|
|
for (const [key, value] of incomingUrl.searchParams.entries()) {
|
|
handshakeUrl.searchParams.set(key, value);
|
|
}
|
|
handshakeUrl.searchParams.set("handshake", "1");
|
|
|
|
const response = await fetchImpl(handshakeUrl, {
|
|
method: "GET",
|
|
headers: {
|
|
authorization: requestHeaders.authorization || "",
|
|
cookie: requestHeaders.cookie || "",
|
|
origin: requestHeaders.origin || "",
|
|
"x-forwarded-for": requestHeaders["x-forwarded-for"] || "",
|
|
},
|
|
});
|
|
|
|
const bodyText = await response.text();
|
|
let bodyJson = null;
|
|
try {
|
|
bodyJson = bodyText ? JSON.parse(bodyText) : null;
|
|
} catch {
|
|
bodyJson = null;
|
|
}
|
|
|
|
return {
|
|
status: response.status,
|
|
headers: Object.fromEntries(response.headers.entries()),
|
|
bodyText,
|
|
bodyJson,
|
|
ok: response.ok,
|
|
};
|
|
}
|
|
|
|
class WebSocketSession {
|
|
constructor(options) {
|
|
this.baseUrl = options.baseUrl;
|
|
this.fetchImpl = options.fetchImpl;
|
|
this.idleTimeoutMs = options.idleTimeoutMs;
|
|
this.pingIntervalMs = options.pingIntervalMs;
|
|
this.socket = options.socket;
|
|
this.requestHeaders = options.requestHeaders;
|
|
this.requestUrl = options.requestUrl;
|
|
this.sessionId = randomUUID();
|
|
this.closed = false;
|
|
this.buffer = Buffer.alloc(0);
|
|
this.fragmentOpcode = null;
|
|
this.fragmentParts = [];
|
|
this.activeRequests = new Map();
|
|
this.lastSeenAt = Date.now();
|
|
|
|
this.pingTimer = setInterval(() => {
|
|
if (this.closed) return;
|
|
const idleForMs = Date.now() - this.lastSeenAt;
|
|
if (idleForMs >= this.idleTimeoutMs) {
|
|
this.close(1001, "idle_timeout");
|
|
return;
|
|
}
|
|
this.sendFrame(0x9);
|
|
}, this.pingIntervalMs);
|
|
|
|
this.socket.setNoDelay(true);
|
|
this.socket.on("data", (chunk) => {
|
|
this.onData(chunk).catch((error) => {
|
|
this.sendProtocolError(
|
|
"frame_decode_failed",
|
|
error instanceof Error ? error.message : String(error)
|
|
);
|
|
});
|
|
});
|
|
this.socket.on("close", () => this.dispose());
|
|
this.socket.on("end", () => this.dispose());
|
|
this.socket.on("error", () => this.dispose());
|
|
}
|
|
|
|
sendFrame(opcode, payload) {
|
|
if (this.closed || this.socket.destroyed) return;
|
|
this.socket.write(encodeWsFrame(opcode, payload));
|
|
}
|
|
|
|
sendJson(payload) {
|
|
this.sendFrame(0x1, Buffer.from(jsonStringifySafe(payload), "utf8"));
|
|
}
|
|
|
|
sendProtocolError(code, message, id = null) {
|
|
this.sendJson({
|
|
type: "protocol.error",
|
|
code,
|
|
id,
|
|
message,
|
|
});
|
|
}
|
|
|
|
async onData(chunk) {
|
|
this.lastSeenAt = Date.now();
|
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
const parsed = decodeClientFrames(this.buffer);
|
|
this.buffer = parsed.remaining;
|
|
|
|
for (const frame of parsed.frames) {
|
|
await this.handleFrame(frame);
|
|
}
|
|
}
|
|
|
|
async handleFrame(frame) {
|
|
switch (frame.opcode) {
|
|
case 0x0:
|
|
if (this.fragmentOpcode === null) {
|
|
this.sendProtocolError("unexpected_continuation", "Unexpected continuation frame");
|
|
return;
|
|
}
|
|
this.fragmentParts.push(frame.payload);
|
|
if (frame.fin) {
|
|
const payload = Buffer.concat(this.fragmentParts);
|
|
const opcode = this.fragmentOpcode;
|
|
this.fragmentOpcode = null;
|
|
this.fragmentParts = [];
|
|
await this.handleDataFrame(opcode, payload);
|
|
}
|
|
return;
|
|
case 0x1:
|
|
case 0x2:
|
|
if (!frame.fin) {
|
|
this.fragmentOpcode = frame.opcode;
|
|
this.fragmentParts = [frame.payload];
|
|
return;
|
|
}
|
|
await this.handleDataFrame(frame.opcode, frame.payload);
|
|
return;
|
|
case 0x8:
|
|
this.close();
|
|
return;
|
|
case 0x9:
|
|
this.sendFrame(0xa, frame.payload);
|
|
return;
|
|
case 0xa:
|
|
this.lastSeenAt = Date.now();
|
|
return;
|
|
default:
|
|
this.sendProtocolError("unsupported_opcode", `Unsupported opcode ${frame.opcode}`);
|
|
}
|
|
}
|
|
|
|
async handleDataFrame(opcode, payload) {
|
|
if (opcode !== 0x1) {
|
|
this.sendProtocolError("unsupported_payload", "Only UTF-8 text messages are supported");
|
|
return;
|
|
}
|
|
|
|
const raw = textDecoder.decode(payload);
|
|
let message;
|
|
try {
|
|
message = JSON.parse(raw);
|
|
} catch {
|
|
this.sendProtocolError("invalid_json", "WebSocket message must be valid JSON");
|
|
return;
|
|
}
|
|
|
|
await this.handleMessage(message);
|
|
}
|
|
|
|
async handleMessage(message) {
|
|
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
|
this.sendProtocolError("invalid_envelope", "WebSocket message must be an object");
|
|
return;
|
|
}
|
|
|
|
if (message.type === "ping") {
|
|
this.sendJson({ type: "pong", sessionId: this.sessionId });
|
|
return;
|
|
}
|
|
|
|
if (message.type === "cancel") {
|
|
const requestId = isText(message.id) ? message.id : null;
|
|
if (!requestId) {
|
|
this.sendProtocolError("invalid_cancel", "cancel envelopes require a string id");
|
|
return;
|
|
}
|
|
const active = this.activeRequests.get(requestId);
|
|
if (!active) {
|
|
this.sendProtocolError(
|
|
"unknown_request",
|
|
"No active request matches the provided id",
|
|
requestId
|
|
);
|
|
return;
|
|
}
|
|
active.abortController.abort();
|
|
return;
|
|
}
|
|
|
|
if (message.type !== "request") {
|
|
this.sendProtocolError(
|
|
"unsupported_type",
|
|
"Supported message types are request, cancel, and ping"
|
|
);
|
|
return;
|
|
}
|
|
|
|
const requestId = isText(message.id) ? message.id : null;
|
|
if (!requestId) {
|
|
this.sendProtocolError("invalid_request_id", "request envelopes require a non-empty id");
|
|
return;
|
|
}
|
|
|
|
if (this.activeRequests.has(requestId)) {
|
|
this.sendProtocolError(
|
|
"duplicate_request",
|
|
"A request with this id is already in flight",
|
|
requestId
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!message.payload || typeof message.payload !== "object" || Array.isArray(message.payload)) {
|
|
this.sendProtocolError(
|
|
"invalid_payload",
|
|
"request envelopes require an object payload",
|
|
requestId
|
|
);
|
|
return;
|
|
}
|
|
|
|
const endpoint = normalizeEndpoint(message.endpoint);
|
|
if (!endpoint) {
|
|
this.sendProtocolError(
|
|
"invalid_endpoint",
|
|
"Endpoint must target a supported /v1 chat surface",
|
|
requestId
|
|
);
|
|
return;
|
|
}
|
|
|
|
const requestPayload = {
|
|
...message.payload,
|
|
stream: message.payload.stream === undefined ? true : message.payload.stream,
|
|
};
|
|
|
|
const abortController = new AbortController();
|
|
this.activeRequests.set(requestId, { abortController });
|
|
this.executeRequest(requestId, endpoint, requestPayload, abortController).catch((error) => {
|
|
this.sendJson({
|
|
type: abortController.signal.aborted ? "response.cancelled" : "response.error",
|
|
id: requestId,
|
|
code: abortController.signal.aborted ? "client_cancelled" : "request_failed",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
this.activeRequests.delete(requestId);
|
|
});
|
|
}
|
|
|
|
async executeRequest(requestId, endpoint, payload, abortController) {
|
|
const headers = {
|
|
...this.requestHeaders,
|
|
accept: payload.stream === false ? "application/json" : "text/event-stream",
|
|
"content-type": "application/json",
|
|
"x-omniroute-ws-session-id": this.sessionId,
|
|
"x-omniroute-ws-request-id": requestId,
|
|
};
|
|
|
|
const response = await this.fetchImpl(new URL(endpoint, this.baseUrl), {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(payload),
|
|
signal: abortController.signal,
|
|
});
|
|
|
|
const contentType = response.headers.get("content-type") || "";
|
|
this.sendJson({
|
|
type: "response.start",
|
|
id: requestId,
|
|
status: response.status,
|
|
ok: response.ok,
|
|
contentType,
|
|
endpoint,
|
|
});
|
|
|
|
if (contentType.includes("text/event-stream") && response.body) {
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
const chunk = decoder.decode(value, { stream: true });
|
|
if (chunk) {
|
|
this.sendJson({
|
|
type: "response.chunk",
|
|
id: requestId,
|
|
chunk,
|
|
});
|
|
}
|
|
}
|
|
|
|
const tail = decoder.decode();
|
|
if (tail) {
|
|
this.sendJson({
|
|
type: "response.chunk",
|
|
id: requestId,
|
|
chunk: tail,
|
|
});
|
|
}
|
|
} finally {
|
|
this.activeRequests.delete(requestId);
|
|
}
|
|
|
|
this.sendJson({
|
|
type: response.ok ? "response.completed" : "response.error",
|
|
id: requestId,
|
|
status: response.status,
|
|
ok: response.ok,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const bodyText = await response.text();
|
|
let body = bodyText;
|
|
try {
|
|
body = bodyText ? JSON.parse(bodyText) : null;
|
|
} catch {
|
|
body = bodyText;
|
|
}
|
|
|
|
this.activeRequests.delete(requestId);
|
|
this.sendJson({
|
|
type: response.ok ? "response.output" : "response.error",
|
|
id: requestId,
|
|
status: response.status,
|
|
ok: response.ok,
|
|
body,
|
|
});
|
|
this.sendJson({
|
|
type: "response.completed",
|
|
id: requestId,
|
|
status: response.status,
|
|
ok: response.ok,
|
|
});
|
|
}
|
|
|
|
close(code = 1000, reason = "normal_closure") {
|
|
if (this.closed) return;
|
|
this.closed = true;
|
|
|
|
clearInterval(this.pingTimer);
|
|
for (const active of this.activeRequests.values()) {
|
|
active.abortController.abort();
|
|
}
|
|
this.activeRequests.clear();
|
|
|
|
const reasonBuffer = Buffer.from(reason, "utf8");
|
|
const payload = Buffer.allocUnsafe(2 + reasonBuffer.length);
|
|
payload.writeUInt16BE(code, 0);
|
|
reasonBuffer.copy(payload, 2);
|
|
this.sendFrame(0x8, payload);
|
|
this.socket.end();
|
|
setTimeout(() => {
|
|
if (!this.socket.destroyed) {
|
|
this.socket.destroy();
|
|
}
|
|
}, 50).unref?.();
|
|
}
|
|
|
|
dispose() {
|
|
if (this.closed) return;
|
|
this.closed = true;
|
|
clearInterval(this.pingTimer);
|
|
for (const active of this.activeRequests.values()) {
|
|
active.abortController.abort();
|
|
}
|
|
this.activeRequests.clear();
|
|
}
|
|
}
|
|
|
|
export function createOmnirouteWsBridge({
|
|
baseUrl,
|
|
fetchImpl = fetch,
|
|
pingIntervalMs = 25000,
|
|
idleTimeoutMs = 90000,
|
|
} = {}) {
|
|
if (!isText(baseUrl)) {
|
|
throw new Error("createOmnirouteWsBridge requires a baseUrl");
|
|
}
|
|
|
|
return {
|
|
isWsPath,
|
|
async handleUpgrade(req, socket, head) {
|
|
const pathname = new URL(req.url || "/", baseUrl).pathname;
|
|
if (!isWsPath(pathname)) {
|
|
return false;
|
|
}
|
|
|
|
const upgradeHeader = String(req.headers.upgrade || "").toLowerCase();
|
|
if (upgradeHeader !== "websocket") {
|
|
writeHttpError(
|
|
socket,
|
|
426,
|
|
JSON.stringify({
|
|
error: {
|
|
message: "Upgrade Required",
|
|
code: "upgrade_required",
|
|
},
|
|
}),
|
|
{ Upgrade: "websocket" }
|
|
);
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const handshake = await performHandshake(fetchImpl, baseUrl, req.url || "/", req.headers);
|
|
if (!handshake.ok) {
|
|
writeHttpError(socket, handshake.status, handshake.bodyText || "{}", handshake.headers);
|
|
return true;
|
|
}
|
|
|
|
const wsKey = req.headers["sec-websocket-key"];
|
|
if (!isText(wsKey)) {
|
|
writeHttpError(
|
|
socket,
|
|
400,
|
|
JSON.stringify({
|
|
error: {
|
|
message: "Missing sec-websocket-key header",
|
|
code: "bad_websocket_handshake",
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
const acceptKey = createHash("sha1").update(`${wsKey}${WS_GUID}`).digest("base64");
|
|
|
|
const headers = [
|
|
"HTTP/1.1 101 Switching Protocols",
|
|
"Upgrade: websocket",
|
|
"Connection: Upgrade",
|
|
`Sec-WebSocket-Accept: ${acceptKey}`,
|
|
"",
|
|
"",
|
|
].join("\r\n");
|
|
|
|
socket.write(headers);
|
|
if (head && head.length > 0) {
|
|
socket.unshift(head);
|
|
}
|
|
|
|
const session = new WebSocketSession({
|
|
baseUrl,
|
|
fetchImpl,
|
|
idleTimeoutMs,
|
|
pingIntervalMs,
|
|
socket,
|
|
requestUrl: req.url || pathname,
|
|
requestHeaders: getForwardHeaders(req.url || pathname, req.headers),
|
|
});
|
|
session.sendJson({
|
|
type: "session.ready",
|
|
sessionId: session.sessionId,
|
|
path: handshake.bodyJson?.path || pathname,
|
|
wsAuth: handshake.bodyJson?.wsAuth === true,
|
|
authenticated: handshake.bodyJson?.authenticated === true,
|
|
authType: handshake.bodyJson?.authType || "none",
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
writeHttpError(
|
|
socket,
|
|
500,
|
|
JSON.stringify({
|
|
error: {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
code: "websocket_bridge_failed",
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
},
|
|
};
|
|
}
|