Skip to content

feat: support Lambda response streaming end-to-end#179

Open
alexis779 wants to merge 1 commit into
aws:developfrom
alexis779:feat/response-streaming
Open

feat: support Lambda response streaming end-to-end#179
alexis779 wants to merge 1 commit into
aws:developfrom
alexis779:feat/response-streaming

Conversation

@alexis779
Copy link
Copy Markdown

Summary

This PR is the RIE half of the local Lambda response-streaming fix tracked by #175 (and the SAM CLI side aws/aws-sam-cli#6501).

Most of the design rationale, end-to-end test plan, and validation evidence live in the companion SAM CLI PR: aws/aws-sam-cli#9028. Please review the two together.

What this changes

The non-direct invoke path (the one SAM CLI hits over /2015-03-31/functions/function/invocations) was:

  1. io.ReadAll-ing the runtime's /response body in rapidcore.Server.sendResponseUnsafe before writing a single slab to the caller, and
  2. Storing that slab in a []byte field inside rie.ResponseWriterProxy until rie.InvokeHandler did one final w.Write(invokeResp.Body).

Both layers had to drain before the caller saw any byte, which made Server-Sent Events and any other Lambda response-streaming workload impossible to forward through RIE. This PR wires the real http.ResponseWriter end-to-end:

  • internal/lambda/rie/util.goResponseWriterProxy now optionally wraps an underlying http.ResponseWriter. On the first Write it copies staged headers / status onto the underlying writer, then forwards every Write straight through and calls Flush() if the underlying supports http.Flusher. The legacy body buffer is kept for pre-stream error paths in InvokeHandler.
  • internal/lambda/rapidcore/server.gosendResponseUnsafe detects streaming via additionalHeaders["Lambda-Runtime-Function-Response-Mode"] (matched case-insensitively, since the runtime sends streaming while the interop constant is Streaming) and uses a new streamingCopy helper. The helper uses a tiny 4 KiB buffer and an explicit Flush after every Write so SSE frames a few dozen bytes long are not coalesced by Go's default 32 KiB io.Copy buffer. The buffered (legacy) branch is preserved and continues to enforce interop.MaxPayloadSize.
  • internal/lambda/rie/handlers.go — wires the real w into the proxy and skips the trailing WriteHeader/Write once streaming has begun. The error code paths that re-emit invokeResp.Body (ErrInitDoneFailed, ErrInvokeDoneFailed) are gated on the new Started flag so we never try to overwrite an in-flight stream.

Test plan

go test ./internal/lambda/...

All existing tests pass. End-to-end validation (Node.js handler using awslambda.streamifyResponse, the RIE binary built from this branch, SAM CLI's start-api consuming it, and a browser EventSource) is documented in aws/aws-sam-cli#9028 — the 10 SSE frames arrive at the browser at ~1 second intervals rather than all at once at the end, with the JSON HTTP-integration prelude correctly forwarded to the caller via the Content-Type and Lambda-Runtime-Function-Response-Mode headers.

Notes

  • This only touches the non-direct (SAM/RIE) code path. The direct-invoke streaming path in internal/lambda/core/directinvoke is unchanged.
  • No new public API; the change is fully transparent for clients that don't use streaming.

Made with Cursor

The non-direct invoke path used by the local Runtime Interface Emulator
(the one SAM CLI talks to over /2015-03-31/functions/function/invocations)
was buffering the runtime's /response body via io.ReadAll before writing
a single slab back to the caller, and the rie.ResponseWriterProxy was
storing that slab in a []byte. Both layers had to drain before the
caller saw any byte, which made Server-Sent Events and other Lambda
response-streaming workloads impossible to test locally.

This change wires the real http.ResponseWriter all the way down:

* rie.ResponseWriterProxy gains an Underlying http.ResponseWriter and
  becomes a streaming pass-through that copies staged headers/status on
  the first Write, then forwards every Write to the underlying writer
  and Flushes after each one. The body buffer is still kept for the
  pre-stream error paths in InvokeHandler.

* rapidcore.Server.sendResponseUnsafe now detects streaming responses
  via additionalHeaders[Lambda-Runtime-Function-Response-Mode] (matched
  case-insensitively, since the runtime sends "streaming" and the
  interop constant is "Streaming") and uses a new streamingCopy helper
  that pipes the runtime's POST body through to the reply stream with
  a tiny 4KiB buffer and an explicit Flush after every Write. The
  buffered (legacy) branch is preserved and continues to enforce
  interop.MaxPayloadSize for non-streaming responses.

* rie.InvokeHandler creates the proxy with the real ResponseWriter and
  skips the trailing WriteHeader/Write if streaming has already begun.
  Error code paths that would emit a synthetic body are gated on the
  new Started flag so we never try to overwrite an in-flight stream.

End-to-end test: a Node.js handler using awslambda.streamifyResponse
that emits one SSE frame per second for 10 seconds now reaches an
EventSource client (and curl -N) one frame at a time, with the JSON
HTTP-integration prelude correctly forwarded to the caller via the
Content-Type and Lambda-Runtime-Function-Response-Mode headers. All
existing unit tests under internal/lambda/... still pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
@alexis779 alexis779 force-pushed the feat/response-streaming branch from 8c9a727 to 1e2a989 Compare May 18, 2026 18:17
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.

1 participant