Skip to content

fix: preserve multi-value response headers in dev#646

Merged
serhalp merged 5 commits intonetlify:mainfrom
aguynamedben:ben/fix-vite-plugin-header-loss
Apr 14, 2026
Merged

fix: preserve multi-value response headers in dev#646
serhalp merged 5 commits intonetlify:mainfrom
aguynamedben:ben/fix-vite-plugin-header-loss

Conversation

@aguynamedben
Copy link
Copy Markdown
Contributor

Summary

  • preserve both headers and multiValueHeaders when @netlify/functions-dev reconstructs a Web Response from the lambda-style result
  • add regression tests covering both plain JSON responses and streamed responses with headers

This fixes a local dev mismatch where Functions served through @netlify/vite-plugin returned the correct body but silently lost response headers such as content-type, cache-control, www-authenticate, CORS headers, and custom headers. The same Functions served through netlify dev preserved those headers.

The root cause was that webResponseFromLambdaResponse(...) only read lambdaResponse.headers, while the real runtime result for these functions was carrying the response metadata in multiValueHeaders.

Example

A standalone repro app that compares @netlify/vite-plugin against netlify dev showed this before the fix:

BAD  @netlify/vite-plugin /api/auth-challenge  status=401
   missing content-type
   missing www-authenticate
   missing access-control-allow-origin
   missing access-control-expose-headers
   missing x-repro-header
   body: {"error":"Unauthorized"}

GOOD netlify dev /api/auth-challenge  status=401
   present content-type: application/json
   present www-authenticate: Bearer realm="repro", resource_metadata="http://localhost:8888/.well-known/oauth-protected-resource/api/auth-challenge"
   present access-control-allow-origin: *
   present access-control-expose-headers: WWW-Authenticate
   present x-repro-header: challenge
   body: {"error":"Unauthorized"}

After this change, the same repro goes green for:

  • /api/headers
  • /api/auth-challenge
  • /api/sse

Test plan

  • npm run test --workspace=@netlify/functions-dev
  • Run a standalone repro that compares @netlify/vite-plugin vs netlify dev
  • Verify the plugin path now preserves headers for plain, auth-challenge, and streamed responses

Made with Cursor

Functions served through the Vite plugin were rebuilding Web Responses
from lambda results using only `headers`, but these v2 functions were
returning their response metadata in `multiValueHeaders`. Merge both
sets when reconstructing the Response and add regression tests for
plain and streamed responses.

Made-with: Cursor
@aguynamedben aguynamedben requested a review from a team as a code owner April 11, 2026 00:33
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 11, 2026

📝 Walkthrough

Walkthrough

Adds two Vitest cases under "Functions with the v2 API syntax" that verify header propagation from v2 Response objects through functions.match(...).handle(req) for both non-streamed and streamed responses. Refactors Lambda response header handling by replacing webHeadersFromHeadersObject with a new webHeadersFromLambdaResponse(lambdaResponse) which constructs a Headers instance from both headers and multiValueHeaders; webResponseFromLambdaResponse now uses this new converter.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The description is well-related to the changeset, explaining the issue, root cause, examples, and test plan for preserving both headers and multiValueHeaders in response reconstruction.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title accurately summarizes the main change: preserving multi-value response headers in the dev runtime, which is the core fix addressed in the PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/functions/dev/src/main.test.ts (1)

153-194: Recommended: add fixture cleanup in both new tests.

These tests create temporary fixtures but don’t call fixture.destroy(). Wrapping each test body in try/finally will reduce leakage/flakiness when assertions fail early.

♻️ Suggested pattern
   const fixture = new Fixture().withFile('netlify/functions/headers.mjs', source)
-  const directory = await fixture.create()
-  const destPath = join(directory, 'functions-serve')
-  const functions = new FunctionsHandler({ ... })
-
-  const req = new Request('https://site.netlify/headers')
-  const match = await functions.match(req, destPath)
-  expect(match).not.toBeUndefined()
-
-  const res = await match!.handle(req)
-  expect(res.status).toBe(401)
-  ...
+  const directory = await fixture.create()
+  try {
+    const destPath = join(directory, 'functions-serve')
+    const functions = new FunctionsHandler({ ... })
+
+    const req = new Request('https://site.netlify/headers')
+    const match = await functions.match(req, destPath)
+    expect(match).not.toBeUndefined()
+
+    const res = await match!.handle(req)
+    expect(res.status).toBe(401)
+    ...
+  } finally {
+    await fixture.destroy()
+  }

Also applies to: 196-243

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/functions/dev/src/main.test.ts` around lines 153 - 194, The new
tests create temporary fixtures via Fixture().withFile(...) and fixture.create()
but never call fixture.destroy(), which can leak temp files if assertions fail;
wrap each test body (both the 'Preserves response headers for v2 functions' test
and the similar test at lines 196-243) in a try/finally and call
fixture.destroy() in the finally block (i.e., ensure fixture.destroy() is
invoked after using fixture, matching the lifecycle created by
Fixture().withFile and fixture.create()).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/functions/dev/src/main.test.ts`:
- Around line 153-194: The new tests create temporary fixtures via
Fixture().withFile(...) and fixture.create() but never call fixture.destroy(),
which can leak temp files if assertions fail; wrap each test body (both the
'Preserves response headers for v2 functions' test and the similar test at lines
196-243) in a try/finally and call fixture.destroy() in the finally block (i.e.,
ensure fixture.destroy() is invoked after using fixture, matching the lifecycle
created by Fixture().withFile and fixture.create()).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8882c20e-d979-44d5-8771-ec2da31cb4e0

📥 Commits

Reviewing files that changed from the base of the PR and between 7f49a53 and 7254a38.

📒 Files selected for processing (2)
  • packages/functions/dev/src/main.test.ts
  • packages/functions/dev/src/runtimes/nodejs/lambda.ts

@aguynamedben
Copy link
Copy Markdown
Contributor Author

This was found while developing a Netlify Function that serves an MCP server using Streamable HTTP responses and testing it with Claude Code. Everything worked fine when running the edge function using netlify dev, but after migrating local development to @netlify/vite-plugin Claude Code could not longer connect reliably.

After investigation, we found that the local Netlify function runtime path used by @netlify/vite-plugin was dropping HTTP headers that were critical to MCP’s auth and transport flow. Specifically, when reconstructing the final Web Response it only read the single-value headers field and ignored multiValueHeaders, which is where the actual response metadata was being carried in this code path.

In our real failure case, the important HTTP headers that went missing on the Vite-plugin path included:

  • WWW-Authenticate
  • Access-Control-Allow-Origin
  • Access-Control-Expose-Headers
  • Content-Type

And for the streamed MCP case specifically, losing Content-Type: text/event-stream was especially important, because Claude Code depends on the correct streamed response headers to establish and maintain the MCP connection. The same MCP server worked correctly when served through netlify dev.

@aguynamedben
Copy link
Copy Markdown
Contributor Author

aguynamedben commented Apr 11, 2026

Fixes #647

@aguynamedben
Copy link
Copy Markdown
Contributor Author

aguynamedben commented Apr 11, 2026

The automated PR review has a "nit":

In `@packages/functions/dev/src/main.test.ts`:
- Around line 153-194: The new tests create temporary fixtures via
...(and so on)

However, I'm actively ignoring that. None of the other tests in main.test.ts worry about that, and my new test follow the patterns already established in the file.

aguynamedben and others added 2 commits April 10, 2026 18:19
Drop the now-unused webHeadersFromHeadersObject helper after switching
response reconstruction to operate on the full lambda response shape.

Made-with: Cursor
@eduardoboucas
Copy link
Copy Markdown
Member

@aguynamedben this is great, thank you! The failures in the CI are due to some weirdness in our current setup. Let me get that sorted and I'll get this shipped.

@serhalp serhalp self-assigned this Apr 14, 2026
@serhalp serhalp changed the title Preserve multiValueHeaders when reconstructing function responses fix: preserve multi-value response headers in dev Apr 14, 2026
@serhalp serhalp merged commit f18e24b into netlify:main Apr 14, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants