From 0648e9e420f7392476ca88ddf00c4f59ec3656e3 Mon Sep 17 00:00:00 2001 From: dajinglingpake <1753473884@qq.com> Date: Wed, 5 Aug 2026 10:31:40 +0700 Subject: [PATCH] fix(server): support IntelliJ IDEA OpenAI clients over HTTP JetBrains Runtime (JBR 25+) sends an h2c upgrade on OpenAI-compatible requests, which the HTTP/1.1 server would otherwise close. Intercept the upgrade, replay the buffered request through the existing handler, and respond over HTTP/1.1. --- custom-server.js | 48 +++++++++++++++++++- tests/unit/custom-server-h2c.test.cjs | 65 +++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/unit/custom-server-h2c.test.cjs diff --git a/custom-server.js b/custom-server.js index f21d4366..cf824ba4 100644 --- a/custom-server.js +++ b/custom-server.js @@ -65,7 +65,53 @@ http.createServer = (...args) => { server.once("listening", () => { startBackgroundTokenRefreshFromCustomServer(); }); + const origEmit = server.emit; + // JBR 25 sends h2c upgrades that the HTTP/1.1 server would otherwise close. + server.emit = function (event, ...eventArgs) { + const [req, socket, head] = eventArgs; + if (event !== "upgrade" || String(req.headers.upgrade || "").toLowerCase() !== "h2c") { + return origEmit.call(this, event, ...eventArgs); + } + + const contentLength = Number(req.headers["content-length"] || 0); + if (!Number.isSafeInteger(contentLength) || contentLength < 0) { + socket.destroy(); + return true; + } + const chunks = [head]; + let received = head.length; + const serve = () => { + // Replay the upgraded request through the existing HTTP/1.1 handler. + const replay = new http.IncomingMessage(socket); + Object.assign(replay, { method: req.method, url: req.url, headers: req.headers, complete: true }); + if (received) replay.push(Buffer.concat(chunks, received).subarray(0, contentLength)); + replay.push(null); + const res = new http.ServerResponse(replay); + res.shouldKeepAlive = false; + res.assignSocket(socket); + res.once("finish", () => socket.end()); + Promise.resolve().then(() => wrapped(replay, res)).catch((error) => { + console.error("Failed to downgrade h2c request", error); + socket.destroy(); + }); + }; + if (received >= contentLength) serve(); + else { + socket.on("data", function readBody(chunk) { + chunks.push(chunk); + received += chunk.length; + if (received < contentLength) return; + socket.off("data", readBody); + serve(); + }); + socket.resume(); + } + delete req.headers.upgrade; + delete req.headers["http2-settings"]; + req.headers.connection = "close"; + return true; + }; return server; }; -require("./server.js"); +if (require.main === module) require("./server.js"); diff --git a/tests/unit/custom-server-h2c.test.cjs b/tests/unit/custom-server-h2c.test.cjs new file mode 100644 index 00000000..5a2c1b06 --- /dev/null +++ b/tests/unit/custom-server-h2c.test.cjs @@ -0,0 +1,65 @@ +const assert = require("node:assert/strict"); +const http = require("node:http"); +const net = require("node:net"); +const test = require("node:test"); + +test("serves h2c POST requests as HTTP/1.1", async () => { + const originalCreateServer = http.createServer; + delete require.cache[require.resolve("../../custom-server.js")]; + require("../../custom-server.js"); + + const server = http.createServer(async (req, res) => { + assert.equal(req.url, "/v1/chat/completions"); + assert.equal(req.headers.upgrade, undefined); + assert.equal(req.headers["http2-settings"], undefined); + assert.equal(req.headers.connection, "close"); + const body = []; + for await (const chunk of req) body.push(chunk); + assert.equal(Buffer.concat(body).toString("utf8"), '{"model":"test","stream":true}'); + res.setHeader("Content-Type", "text/event-stream"); + res.end("data: [DONE]\n\n"); + }); + server.on("upgrade", (_req, socket) => socket.destroy()); + + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = server.address().port; + + const response = await new Promise((resolve, reject) => { + const chunks = []; + const socket = net.createConnection({ host: "127.0.0.1", port }, () => { + const body = '{"model":"test","stream":true}'; + socket.write([ + "POST /v1/chat/completions HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Connection: Upgrade, HTTP2-Settings", + "Upgrade: h2c", + "HTTP2-Settings: AAEAAEAAAAIAAAAAAAMAAAAAAAQBAAAAAAUAAEAAAAYABgAA", + `Content-Length: ${Buffer.byteLength(body)}`, + "Content-Type: application/json", + "", + "", + ].join("\r\n")); + setImmediate(() => socket.write(body)); + }); + socket.setTimeout(2_000, () => { + socket.destroy(); + reject(new Error("h2c fallback response timed out")); + }); + socket.on("data", (chunk) => chunks.push(chunk)); + socket.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + socket.on("error", reject); + }); + + assert.match(response, /^HTTP\/1\.1 200 OK\r\n/); + assert.match(response, /\r\nContent-Type: text\/event-stream\r\n/i); + assert.match(response, /\r\nConnection: close\r\n/i); + assert.match(response, /\r\n\r\ndata: \[DONE\]\n\n$/); + } finally { + await new Promise((resolve) => server.close(resolve)); + http.createServer = originalCreateServer; + } +});