api9nin / src /mitm /handlers /base.js
github-actions[bot]
deploy from github actions 2026-06-18
c8ae75d
Raw
History Blame Contribute Delete
7.48 kB
const { log, err } = require("../logger");
const DEFAULT_LOCAL_ROUTER = "http://localhost:20128";
const ROUTER_BASE = String(process.env.MITM_ROUTER_BASE || DEFAULT_LOCAL_ROUTER)
.trim()
.replace(/\/+$/, "") || DEFAULT_LOCAL_ROUTER;
const API_KEY = process.env.ROUTER_API_KEY;
// Headers that must not be forwarded to 9Router
const STRIP_HEADERS = new Set([
"host", "content-length", "connection", "transfer-encoding",
"content-type", "authorization"
]);
/**
* Send body to 9Router at the given path and return the fetch Response object.
* Optionally forwards client headers (stripped of hop-by-hop / overridden keys).
*/
async function fetchRouter(openaiBody, path = "/v1/chat/completions", clientHeaders = {}) {
const forwarded = {};
for (const [k, v] of Object.entries(clientHeaders)) {
if (!STRIP_HEADERS.has(k.toLowerCase())) forwarded[k] = v;
}
const response = await fetch(`${ROUTER_BASE}${path}`, {
method: "POST",
headers: {
...forwarded,
"Content-Type": "application/json",
...(API_KEY && { "Authorization": `Bearer ${API_KEY}` })
},
body: JSON.stringify(openaiBody)
});
// Forward response as-is (status + body). pipeSSE will propagate status.
return response;
}
/**
* Pipe SSE stream from router directly to client response.
* Optional dumper tees the stream into a debug file.
*/
async function pipeSSE(routerRes, res, dumper) {
const ct = routerRes.headers.get("content-type") || "application/json";
const status = routerRes.status || 200;
const resHeaders = { "Content-Type": ct, "Cache-Control": "no-cache", "Connection": "keep-alive" };
if (ct.includes("text/event-stream")) resHeaders["X-Accel-Buffering"] = "no";
res.writeHead(status, resHeaders);
if (dumper) dumper.writeHeader(routerRes.status, Object.fromEntries(routerRes.headers));
if (!routerRes.body) {
const text = await routerRes.text().catch(() => "");
if (dumper) { dumper.writeChunk(text); dumper.end(); }
res.end(text);
return;
}
const reader = routerRes.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) { if (dumper) dumper.end(); res.end(); break; }
if (dumper) dumper.writeChunk(value);
res.write(decoder.decode(value, { stream: true }));
}
}
/**
* Pipe SSE stream from router, transforming each chunk through a user function.
* Reads SSE data: lines, parses JSON, calls transformFn(parsed, state),
* and writes returned SSE strings to the client response.
*
* @param {Response} routerRes - Fetch Response from 9Router
* @param {http.ServerResponse} res - Client response
* @param {Function} transformFn - (parsedChunk, state) => string|string[]|null
* @param {object} state - Mutable state object shared across chunks and flush
*/
async function pipeTransformedSSE(routerRes, res, transformFn, state) {
const ct = routerRes.headers.get("content-type") || "application/json";
const resHeaders = { "Content-Type": ct, "Cache-Control": "no-cache", "Connection": "keep-alive" };
if (ct.includes("text/event-stream")) resHeaders["X-Accel-Buffering"] = "no";
res.writeHead(200, resHeaders);
if (!routerRes.body) {
res.end(await routerRes.text().catch(() => ""));
return;
}
const reader = routerRes.body.getReader();
const decoder = new TextDecoder("utf-8", { fatal: false });
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data:")) continue;
const data = trimmed.slice(5).trim();
if (data === "[DONE]") continue;
if (process.env.DEBUG_MITM) {
log(`[SSE in] ${data.slice(0, 200)}`);
}
try {
const parsed = JSON.parse(data);
const result = transformFn(parsed, state);
if (result != null) {
const outputs = Array.isArray(result) ? result : [result];
for (const output of outputs) {
if (process.env.DEBUG_MITM) {
const len = output.length || output.byteLength || 0;
log(`[write binary frame] (${len}B) first 20B: ${Array.from(output.slice(0, 20)).join(',')}`);
}
res.write(Buffer.from(output));
}
}
} catch {
// Skip unparseable lines
}
}
}
// Flush: pass null to signal stream end
try {
const flushed = transformFn(null, state);
if (flushed != null) {
const outputs = Array.isArray(flushed) ? flushed : [flushed];
for (const output of outputs) {
res.write(output);
}
}
} catch { /* ignore flush errors */ }
res.end();
}
/**
* Pipe SSE stream from router, transforming each chunk through a user function,
* and writing binary EventStream frames to the client.
*
* Reads SSE data: lines, parses JSON, calls transformFn(parsed, state),
* and writes returned Uint8Array frames to the client response.
*
* @param {Response} routerRes - Fetch Response from 9Router
* @param {http.ServerResponse} res - Client response
* @param {Function} transformFn - (parsedChunk, state) => Uint8Array|Uint8Array[]|null
* @param {object} state - Mutable state object shared across chunks and flush
*/
async function pipeTransformedEventStream(routerRes, res, transformFn, state) {
const resHeaders = {
"Content-Type": "application/vnd.amazon.eventstream",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
};
res.writeHead(200, resHeaders);
if (!routerRes.body) {
res.end(await routerRes.text().catch(() => ""));
return;
}
const reader = routerRes.body.getReader();
const decoder = new TextDecoder("utf-8", { fatal: false });
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data:")) continue;
const data = trimmed.slice(5).trim();
if (data === "[DONE]") continue;
if (process.env.DEBUG_MITM) {
log(`[SSE in] ${data.slice(0, 200)}`);
}
try {
const parsed = JSON.parse(data);
const result = transformFn(parsed, state);
if (result != null) {
const outputs = Array.isArray(result) ? result : [result];
for (const output of outputs) {
if (process.env.DEBUG_MITM) {
const len = output.length || output.byteLength || 0;
log(`[write binary frame] (${len}B) first 20B: ${Array.from(output.slice(0, 20)).join(',')}`);
}
res.write(Buffer.from(output));
}
}
} catch {
// Skip unparseable lines
}
}
}
// Flush: pass null to signal stream end
try {
const flushed = transformFn(null, state);
if (flushed != null) {
const outputs = Array.isArray(flushed) ? flushed : [flushed];
for (const output of outputs) {
res.write(output);
}
}
} catch { /* ignore flush errors */ }
res.end();
}
module.exports = { fetchRouter, pipeSSE, pipeTransformedSSE, pipeTransformedEventStream };