From c59745367d1fea3de8c4f9ff1fd56e902e449362 Mon Sep 17 00:00:00 2001 From: Dennis Oelkers Date: Thu, 3 Sep 2026 10:42:40 +0200 Subject: [PATCH 1/3] fix(server): don't 404 non-websocket upgrade probes like h2c Node fires the "upgrade" event (not "request") for any request with a Connection: Upgrade header, regardless of the target protocol. OkHttp (used by langchain4j) speculatively sends Upgrade: h2c on plain requests to probe for HTTP/2 cleartext, which made GET /api/tags land in the WebSocket-only upgrade handler and get an unconditional 404. Only treat the request as a WebSocket upgrade when Upgrade: websocket is actually present; otherwise fall through to the normal HTTP pipeline so routes like /api/tags still work. --- src/__tests__/ollama.test.ts | 53 ++++++++++++++++++++++++++++++++++++ src/server.ts | 19 +++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/__tests__/ollama.test.ts b/src/__tests__/ollama.test.ts index e409825e..df4688b8 100644 --- a/src/__tests__/ollama.test.ts +++ b/src/__tests__/ollama.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, afterEach } from "vitest"; import * as http from "node:http"; +import * as net from "node:net"; import type { Fixture, HandlerDefaults } from "../types.js"; import { createServer, type ServerInstance } from "../server.js"; import { ollamaToCompletionRequest, handleOllama, handleOllamaGenerate } from "../ollama.js"; @@ -106,6 +107,43 @@ function postRaw(url: string, raw: string): Promise<{ status: number; body: stri }); } +// Sends a raw GET with `Upgrade: h2c` + `Connection: Upgrade` headers, as +// OkHttp (and thus langchain4j) speculatively does even for plain requests. +// http.request() can't produce this from the client side, so we use a raw +// socket and parse the response ourselves. +function getWithH2cUpgradeHeaders(url: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const port = parsed.port ? Number(parsed.port) : 80; + const socket = net.connect(port, parsed.hostname, () => { + socket.write( + `GET ${parsed.pathname} HTTP/1.1\r\n` + + `Host: ${parsed.host}\r\n` + + `Upgrade: h2c\r\n` + + `Connection: Upgrade\r\n` + + `\r\n`, + ); + }); + let data = ""; + socket.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); + socket.on("error", reject); + socket.on("close", () => { + const [head, ...rest] = data.split("\r\n\r\n"); + const statusLine = head.split("\r\n")[0] ?? ""; + const status = Number(statusLine.split(" ")[1] ?? 0); + // Body may be chunked; strip chunk-size lines for the assertions below. + const body = rest + .join("\r\n\r\n") + .split("\r\n") + .filter((line) => !/^[0-9a-f]+$/i.test(line.trim())) + .join(""); + resolve({ status, body }); + }); + }); +} + function parseNDJSON(body: string): object[] { return body .split("\n") @@ -882,6 +920,21 @@ describe("GET /api/tags", () => { const names = body.models.map((m: { name: string }) => m.name); expect(names).toContain("gpt-4"); }); + + // Regression test: langchain4j's OkHttp-based client probes for HTTP/2 + // cleartext support by sending `Upgrade: h2c` + `Connection: Upgrade` on + // its plain GET /api/tags request. Node's http module treats any + // `Connection: Upgrade` header as a protocol-upgrade request, so this must + // not be swallowed by the WebSocket-upgrade path and 404. + it("responds normally when the request carries h2c upgrade probe headers", async () => { + instance = await createServer(allFixtures); + const res = await getWithH2cUpgradeHeaders(`${instance.url}/api/tags`); + + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + const names = body.models.map((m: { name: string }) => m.name); + expect(names).toContain("llama3"); + }); }); // ─── Integration tests: journal ───────────────────────────────────────────── diff --git a/src/server.ts b/src/server.ts index 7a77f46f..57f27d11 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3145,6 +3145,25 @@ export async function createServerWithResolvedAuth( socket: import("node:net").Socket, head: Buffer, ): Promise { + // Node emits "upgrade" (never "request") for ANY request carrying a + // `Connection: Upgrade` header, regardless of what's being upgraded to. + // Some HTTP clients (e.g. OkHttp, used by langchain4j) speculatively send + // `Upgrade: h2c` + `Connection: Upgrade` on plain requests to probe for + // HTTP/2 cleartext support. Only actual WebSocket upgrades belong on this + // path — anything else must fall through to the normal HTTP pipeline so + // routes like `/api/tags` still work. + if ((req.headers.upgrade ?? "").toLowerCase() !== "websocket") { + if (head.length > 0) socket.unshift(head); + const res = new http.ServerResponse(req); + res.assignSocket(socket); + res.on("finish", () => { + res.detachSocket(socket); + socket.end(); + }); + await handleHttpRequest(req, res); + return; + } + const parsedUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); let pathname = parsedUrl.pathname; From aeef742584d0662cc30154c072b812a791fc7b67 Mon Sep 17 00:00:00 2001 From: Dennis Oelkers Date: Thu, 3 Sep 2026 10:46:35 +0200 Subject: [PATCH 2/3] fix(server): don't 404 non-websocket upgrade probes like h2c Node fires the "upgrade" event (not "request") for any request with a Connection: Upgrade header, regardless of the target protocol. OkHttp (used by langchain4j) speculatively sends Upgrade: h2c on plain requests to probe for HTTP/2 cleartext, which made GET /api/tags land in the WebSocket-only upgrade handler and get an unconditional 404. Only treat the request as a WebSocket upgrade when Upgrade: websocket is actually present; otherwise fall through to the normal HTTP pipeline so routes like /api/tags still work. --- src/__tests__/ollama.test.ts | 53 ++++++++++++++++++++++++++++++++++++ src/server.ts | 19 +++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/__tests__/ollama.test.ts b/src/__tests__/ollama.test.ts index e409825e..df4688b8 100644 --- a/src/__tests__/ollama.test.ts +++ b/src/__tests__/ollama.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, afterEach } from "vitest"; import * as http from "node:http"; +import * as net from "node:net"; import type { Fixture, HandlerDefaults } from "../types.js"; import { createServer, type ServerInstance } from "../server.js"; import { ollamaToCompletionRequest, handleOllama, handleOllamaGenerate } from "../ollama.js"; @@ -106,6 +107,43 @@ function postRaw(url: string, raw: string): Promise<{ status: number; body: stri }); } +// Sends a raw GET with `Upgrade: h2c` + `Connection: Upgrade` headers, as +// OkHttp (and thus langchain4j) speculatively does even for plain requests. +// http.request() can't produce this from the client side, so we use a raw +// socket and parse the response ourselves. +function getWithH2cUpgradeHeaders(url: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const port = parsed.port ? Number(parsed.port) : 80; + const socket = net.connect(port, parsed.hostname, () => { + socket.write( + `GET ${parsed.pathname} HTTP/1.1\r\n` + + `Host: ${parsed.host}\r\n` + + `Upgrade: h2c\r\n` + + `Connection: Upgrade\r\n` + + `\r\n`, + ); + }); + let data = ""; + socket.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); + socket.on("error", reject); + socket.on("close", () => { + const [head, ...rest] = data.split("\r\n\r\n"); + const statusLine = head.split("\r\n")[0] ?? ""; + const status = Number(statusLine.split(" ")[1] ?? 0); + // Body may be chunked; strip chunk-size lines for the assertions below. + const body = rest + .join("\r\n\r\n") + .split("\r\n") + .filter((line) => !/^[0-9a-f]+$/i.test(line.trim())) + .join(""); + resolve({ status, body }); + }); + }); +} + function parseNDJSON(body: string): object[] { return body .split("\n") @@ -882,6 +920,21 @@ describe("GET /api/tags", () => { const names = body.models.map((m: { name: string }) => m.name); expect(names).toContain("gpt-4"); }); + + // Regression test: langchain4j's OkHttp-based client probes for HTTP/2 + // cleartext support by sending `Upgrade: h2c` + `Connection: Upgrade` on + // its plain GET /api/tags request. Node's http module treats any + // `Connection: Upgrade` header as a protocol-upgrade request, so this must + // not be swallowed by the WebSocket-upgrade path and 404. + it("responds normally when the request carries h2c upgrade probe headers", async () => { + instance = await createServer(allFixtures); + const res = await getWithH2cUpgradeHeaders(`${instance.url}/api/tags`); + + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + const names = body.models.map((m: { name: string }) => m.name); + expect(names).toContain("llama3"); + }); }); // ─── Integration tests: journal ───────────────────────────────────────────── diff --git a/src/server.ts b/src/server.ts index 7a77f46f..57f27d11 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3145,6 +3145,25 @@ export async function createServerWithResolvedAuth( socket: import("node:net").Socket, head: Buffer, ): Promise { + // Node emits "upgrade" (never "request") for ANY request carrying a + // `Connection: Upgrade` header, regardless of what's being upgraded to. + // Some HTTP clients (e.g. OkHttp, used by langchain4j) speculatively send + // `Upgrade: h2c` + `Connection: Upgrade` on plain requests to probe for + // HTTP/2 cleartext support. Only actual WebSocket upgrades belong on this + // path — anything else must fall through to the normal HTTP pipeline so + // routes like `/api/tags` still work. + if ((req.headers.upgrade ?? "").toLowerCase() !== "websocket") { + if (head.length > 0) socket.unshift(head); + const res = new http.ServerResponse(req); + res.assignSocket(socket); + res.on("finish", () => { + res.detachSocket(socket); + socket.end(); + }); + await handleHttpRequest(req, res); + return; + } + const parsedUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); let pathname = parsedUrl.pathname; From dc89fecc49f1f8044acc6578a580b6033bd6cbdd Mon Sep 17 00:00:00 2001 From: Dennis Oelkers Date: Thu, 3 Sep 2026 14:38:21 +0200 Subject: [PATCH 3/3] fix(server): stop dropping the body of h2c-probed POST requests The earlier h2c-probe fix (rebuilding a ServerResponse via assignSocket) only worked for bodyless requests like GET /api/tags. Node detaches its HTTP parser from the socket the moment "upgrade" fires, so any body bytes land in the `head` buffer instead of `req`'s stream; unshifting them back onto the socket never reconnects them to `req`, so readBody(req) resolved empty. A POST like /api/chat then failed JSON parsing ("Unexpected end of JSON input") instead of surfacing the real validation error for the request that was actually sent. Rebuild the request line and headers with Upgrade/Connection dropped, replay them plus `head` on the socket, and let Node re-parse the connection from scratch via server.emit("connection", socket). This reuses Node's own parser for the body (Content-Length or chunked) instead of hand-rolling body buffering. --- src/__tests__/ollama.test.ts | 76 ++++++++++++++++++++++++++++-------- src/server.ts | 34 ++++++++++------ 2 files changed, 82 insertions(+), 28 deletions(-) diff --git a/src/__tests__/ollama.test.ts b/src/__tests__/ollama.test.ts index df4688b8..48ef877f 100644 --- a/src/__tests__/ollama.test.ts +++ b/src/__tests__/ollama.test.ts @@ -107,40 +107,63 @@ function postRaw(url: string, raw: string): Promise<{ status: number; body: stri }); } -// Sends a raw GET with `Upgrade: h2c` + `Connection: Upgrade` headers, as +// Sends a raw request with `Upgrade: h2c` + `Connection: Upgrade` headers, as // OkHttp (and thus langchain4j) speculatively does even for plain requests. // http.request() can't produce this from the client side, so we use a raw // socket and parse the response ourselves. -function getWithH2cUpgradeHeaders(url: string): Promise<{ status: number; body: string }> { +function requestWithH2cUpgradeHeaders( + url: string, + method: string, + body?: string, +): Promise<{ status: number; body: string }> { return new Promise((resolve, reject) => { const parsed = new URL(url); const port = parsed.port ? Number(parsed.port) : 80; const socket = net.connect(port, parsed.hostname, () => { + const bodyHeaders = body + ? `Content-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\n` + : ""; socket.write( - `GET ${parsed.pathname} HTTP/1.1\r\n` + + `${method} ${parsed.pathname} HTTP/1.1\r\n` + `Host: ${parsed.host}\r\n` + + bodyHeaders + `Upgrade: h2c\r\n` + `Connection: Upgrade\r\n` + - `\r\n`, + `\r\n` + + (body ?? ""), ); }); let data = ""; + // The server keeps the connection alive (we don't send a real + // `Connection: close`), so completion must be detected from the response + // framing itself rather than waiting for the socket to close. socket.on("data", (chunk: Buffer) => { data += chunk.toString(); - }); - socket.on("error", reject); - socket.on("close", () => { - const [head, ...rest] = data.split("\r\n\r\n"); + const headerEnd = data.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const head = data.slice(0, headerEnd); + const rest = data.slice(headerEnd + 4); + const isChunked = /transfer-encoding:\s*chunked/i.test(head); + const contentLengthMatch = head.match(/content-length:\s*(\d+)/i); + const done = isChunked + ? rest.endsWith("0\r\n\r\n") + : contentLengthMatch + ? Buffer.byteLength(rest) >= Number(contentLengthMatch[1]) + : false; + if (!done) return; + const statusLine = head.split("\r\n")[0] ?? ""; const status = Number(statusLine.split(" ")[1] ?? 0); - // Body may be chunked; strip chunk-size lines for the assertions below. - const body = rest - .join("\r\n\r\n") - .split("\r\n") - .filter((line) => !/^[0-9a-f]+$/i.test(line.trim())) - .join(""); - resolve({ status, body }); + const responseBody = isChunked + ? rest + .split("\r\n") + .filter((line) => !/^[0-9a-f]+$/i.test(line.trim())) + .join("") + : rest.slice(0, Number(contentLengthMatch![1])); + socket.destroy(); + resolve({ status, body: responseBody }); }); + socket.on("error", reject); }); } @@ -928,7 +951,7 @@ describe("GET /api/tags", () => { // not be swallowed by the WebSocket-upgrade path and 404. it("responds normally when the request carries h2c upgrade probe headers", async () => { instance = await createServer(allFixtures); - const res = await getWithH2cUpgradeHeaders(`${instance.url}/api/tags`); + const res = await requestWithH2cUpgradeHeaders(`${instance.url}/api/tags`, "GET"); expect(res.status).toBe(200); const body = JSON.parse(res.body); @@ -937,6 +960,27 @@ describe("GET /api/tags", () => { }); }); +describe("POST /api/chat (h2c upgrade probe)", () => { + // Regression test: the same h2c probe headers on a POST with a body used to + // reach the request handler with an empty body (Node detaches its parser + // from `req` once "upgrade" fires, so bytes buffered in `head` never became + // part of `req`'s stream), producing a "Malformed JSON body" error instead + // of the real validation error for the request that was actually sent. + it("still parses the JSON body and returns the real validation error", async () => { + instance = await createServer(allFixtures); + const res = await requestWithH2cUpgradeHeaders( + `${instance.url}/api/chat`, + "POST", + JSON.stringify({ model: "llama3.2" }), + ); + + expect(res.status).toBe(400); + const body = JSON.parse(res.body); + expect(body.error.message).toMatch(/messages/i); + expect(body.error.message).not.toMatch(/Malformed JSON/i); + }); +}); + // ─── Integration tests: journal ───────────────────────────────────────────── describe("POST /api/chat (journal)", () => { diff --git a/src/server.ts b/src/server.ts index 57f27d11..a2388bda 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3148,19 +3148,29 @@ export async function createServerWithResolvedAuth( // Node emits "upgrade" (never "request") for ANY request carrying a // `Connection: Upgrade` header, regardless of what's being upgraded to. // Some HTTP clients (e.g. OkHttp, used by langchain4j) speculatively send - // `Upgrade: h2c` + `Connection: Upgrade` on plain requests to probe for - // HTTP/2 cleartext support. Only actual WebSocket upgrades belong on this - // path — anything else must fall through to the normal HTTP pipeline so - // routes like `/api/tags` still work. + // `Upgrade: h2c` + `Connection: Upgrade` on plain requests — including + // ones with a body — to probe for HTTP/2 cleartext support. Only actual + // WebSocket upgrades belong on this path. + // + // Node detaches its HTTP parser from the socket the moment "upgrade" + // fires, so `req` never receives a body: any bytes already read land in + // `head` instead, and reconnecting them to `req` (e.g. via `req.push`) + // would still leave chunked bodies and further framing unhandled. Rather + // than reimplementing body parsing, rebuild the request line and headers + // with `Upgrade`/`Connection` dropped — the only thing that made this + // look like an upgrade — replay them plus `head` onto the socket, and + // let Node re-parse the connection from scratch as an ordinary request. if ((req.headers.upgrade ?? "").toLowerCase() !== "websocket") { - if (head.length > 0) socket.unshift(head); - const res = new http.ServerResponse(req); - res.assignSocket(socket); - res.on("finish", () => { - res.detachSocket(socket); - socket.end(); - }); - await handleHttpRequest(req, res); + const requestLine = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`; + const headerLines: string[] = []; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + if (/^(?:upgrade|connection)$/i.test(req.rawHeaders[i])) continue; + headerLines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`); + } + const rebuilt = Buffer.from(requestLine + headerLines.join("\r\n") + "\r\n\r\n"); + socket.unshift(head); + socket.unshift(rebuilt); + server.emit("connection", socket); return; }