Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
326 changes: 278 additions & 48 deletions apps/web/src/app/api/opencode/[port]/[[...path]]/route.test.ts

Large diffs are not rendered by default.

75 changes: 56 additions & 19 deletions apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ function buildTargetUrl(port: number, path: string[] = []): string {
return `http://127.0.0.1:${port}${pathString}`
}

/**
* Hop-by-hop headers that should not be forwarded
* See: https://www.rfc-editor.org/rfc/rfc2616#section-13.5.1
*/
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"trailers",
"transfer-encoding",
"upgrade",
])

/**
* Proxy request to OpenCode server
*
Expand All @@ -103,36 +119,52 @@ async function proxyRequest(
const targetUrl = buildTargetUrl(port, path)

try {
// Copy headers from incoming request
const headers = new Headers()

// Preserve OpenCode-specific headers
const directoryHeader = request.headers.get("x-opencode-directory")
if (directoryHeader) {
headers.set("x-opencode-directory", directoryHeader)
// Forward all headers except hop-by-hop headers
// Also parse Connection header tokens per RFC 2616 Section 14.10
// See: https://github.com/joelhooks/opencode-vibe/issues/23
const denyHeaders = new Set(HOP_BY_HOP_HEADERS)
const connectionHeader = request.headers.get("connection")
if (connectionHeader) {
for (const token of connectionHeader.split(",")) {
const trimmed = token.trim().toLowerCase()
if (trimmed) denyHeaders.add(trimmed)
}
}

// Preserve content-type for POST/PUT/PATCH
const contentType = request.headers.get("content-type")
if (contentType) {
headers.set("content-type", contentType)
const headers = new Headers()
for (const [key, value] of request.headers.entries()) {
if (!denyHeaders.has(key)) {
headers.set(key, value)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Copy body for POST/PUT/PATCH
let body: ReadableStream | null = null
// Read body as text for POST/PUT/PATCH
// Using request.text() instead of request.body ReadableStream to avoid
// bun/Next.js 16 compatibility issues with streaming request bodies
// See: https://github.com/joelhooks/opencode-vibe/issues/20
let body: string | undefined
if (["POST", "PUT", "PATCH"].includes(request.method)) {
body = request.body
body = await request.text()
}

// Proxy request to OpenCode server
const response = await fetch(targetUrl, {
method: request.method,
headers,
body,
// @ts-expect-error - duplex mode needed for streaming request bodies
duplex: body ? "half" : undefined,
signal: AbortSignal.timeout(30_000),
})

// Handle 204 No Content - no body to read, but preserve headers
// See: https://github.com/joelhooks/opencode-vibe/issues/20
if (response.status === 204) {
const response204 = new NextResponse(null, { status: 204 })
for (const [key, value] of response.headers.entries()) {
response204.headers.set(key, value)
}
return response204
}

// Handle non-2xx responses
if (!response.ok) {
return NextResponse.json(
Expand All @@ -146,11 +178,16 @@ async function proxyRequest(

// Return proxied response
const responseBody = await response.text()
const responseHeaders = new Headers()
for (const [key, value] of response.headers.entries()) {
responseHeaders.set(key, value)
}
if (!responseHeaders.has("content-type")) {
responseHeaders.set("content-type", "application/json")
}
return new NextResponse(responseBody, {
status: response.status,
headers: {
"Content-Type": response.headers.get("content-type") || "application/json",
},
headers: responseHeaders,
})
} catch (error) {
console.error(`[API Proxy] Failed to proxy to ${targetUrl}:`, error)
Expand Down
Loading