Skip to content

feat(event-handler): HTTP response streaming on Lambda and self-hosted - #5533

Draft
adrians5j wants to merge 4 commits into
nextfrom
claude/http-response-streaming
Draft

feat(event-handler): HTTP response streaming on Lambda and self-hosted#5533
adrians5j wants to merge 4 commits into
nextfrom
claude/http-response-streaming

Conversation

@adrians5j

Copy link
Copy Markdown
Member

Adds incremental HTTP response delivery to Webiny — on self-hosted Node and on AWS Lambda — plus a File Manager "Re-enrich with AI" action that exercises it end to end.

Verified working on both hosting types: self-hosted via webiny-server watch, and on a real AWS deployment through CloudFront with chunks arriving incrementally.

Core primitive

HttpStreamBody (@webiny/event-handler-core) is an explicit marker a route wraps its source in to opt into streaming. Additive — IHttpResponse.body was already any, so no existing route changes. Transports that can stream write chunks as they are produced; transports that cannot call collect().

Duck-typing Symbol.asyncIterator was rejected deliberately: a plain object body could satisfy it accidentally, and ReadableStream's async-iterator support isn't in the DOM types.

Self-hosted

createServerHandler flushes headers, writes per chunk, honours back-pressure, and stops pulling when the client disconnects.

Also fixes a latent bug the streaming path made reachable: the error handler called writeHead(500) unconditionally, which throws ERR_HTTP_HEADERS_SENT once headers are out and masks the real error.

AWS

API Gateway buffers the entire Lambda response regardless of how it's produced, so it cannot stream at all. Streaming needs a Lambda Function URL with InvokeMode: RESPONSE_STREAM, and a Lambda's handler entry is fixed per function — hence a second function off the same bundle (handler.streamHandler), fronted by a Function URL instead of API Gateway.

New transport: Function URL event type, translator (cookies arrive as an array, not a header; no stage prefix to strip), a terminal handler that writes to the response stream, and createStreamLambdaHandler.

The existing API Gateway translator now drains a streaming body instead of failing, so a streaming route still works over that transport as a single buffered response.

Auth

Adds x-webiny-authorization, read ahead of Authorization by both the AWS and self-hosted extractors. Behind CloudFront with Origin Access Control, SigV4 occupies Authorization, so a viewer bearer token can't survive to the origin. A separate header keeps the Function URL private (AWS_IAM + OAC) without depending on that interaction.

Infra

ApiGraphqlStream (Lambda + Function URL, reusing the graphql IAM role, 300s timeout), a CloudFront OAC, and a /stream/* behavior ordered first with compress: false — CloudFront compression buffers chunks and defeats incremental delivery.

File Manager

The AI enrichment logic is extracted out of AiImageEnrichmentTask into Prepare/Apply use cases shared with the new SSE route, so the task and the route can't drift. The route resolves everything it can before opening the stream, so missing file / non-image / no provider / license come back as real status codes rather than buried in a 200.

Frontend adds ApiStreamClient and a generic readServerSentEvents reader, with auth and tenant decorators mirroring the GraphQL client. GraphQLClient couldn't carry this: execute(): Promise<TResult> is a buffered contract by type, and graphql-js 16 has no incremental delivery.

Four bugs found by deploying

Each of these failed silently and would have shipped a non-streaming endpoint that looked fine:

  1. rspack tree-shook streamHandler away. Nothing imports an entry's exports, so the unused one went along with every module reachable only from it. handler survived by accident of being first. Fixed by declaring the entry a module library.
  2. The WCP telemetry wrapper re-exported only handler. It renames the bundle to _handler.mjs and substitutes a downloaded wrapper, so handler.streamHandler didn't exist in the deployed artifact. Re-exported unwrapped (the streamifyResponse marker must survive) via a namespace import (the self-hosted bundle has no such export, and a named re-export of a missing binding is a hard ESM error).
  3. Missing IAM permission. OAC-for-Lambda requires both lambda:InvokeFunctionUrl and lambda:InvokeFunction; with only the first, Lambda denied every signed CloudFront request and the function was never invoked.
  4. The prelude was never flushed. The runtime emits it lazily on the first write, so a body-less response (every CORS preflight is 204) sent nothing and Lambda substituted a default 200 application/octet-stream with all headers dropped. A direct streaming invoke returned 0 bytes. Every path now guarantees one write.

Tests

79 new tests across event-handler-core, event-handler-server, event-handler-aws, app, and ai-powerups. The server test gates its producer on the client having read the first chunk, so a buffering transport deadlocks rather than passing.

Reviewer notes

  • The prelude flush relies on the runtime's _onBeforeFirstWrite, which isn't public API. Tests can only assert we call write once; if AWS changes that hook it regresses to the silent empty-200.
  • We append a re-export to a wrapper downloaded from api.webiny.com/clients/latest.mjs. If that file's shape changes, this breaks.
  • library: { type: "module" } is in shared build-tools and affects every function bundle on both hosting types. Both were verified to build and import, but it has the widest blast radius here.
  • The /stream/* route is a new public surface; authorization leans on the WCP gate plus UpdateFileUseCase. No route-level permission checks were added.
  • The FM button isn't license-gated client-side, so a non-entitled license gets a 403 dialog.
  • OAC does not sign request bodies, so a future streaming route that POSTs a body needs the client to send x-amz-content-sha256. The enrich route sends none.

🤖 Generated with Claude Code

adrians5j and others added 4 commits July 30, 2026 08:56
Adds incremental HTTP response delivery to Webiny, plus a File Manager
"Re-enrich with AI" action that exercises it end to end.

Core (@webiny/event-handler-core)

Introduces `HttpStreamBody`, an explicit marker a route wraps its source
in to opt into streaming. Additive: `IHttpResponse.body` was already
`any`, so existing routes are untouched. Transports that can stream write
chunks as produced; those that cannot call `collect()`.

Self-hosted (@webiny/event-handler-server)

Streams via `res.flushHeaders()` + per-chunk writes, honouring
back-pressure and client disconnects. Also fixes a latent bug the
streaming path made reachable: the error handler called `writeHead(500)`
unconditionally, which throws ERR_HTTP_HEADERS_SENT once headers are out
and masks the real error.

AWS (@webiny/event-handler-aws)

API Gateway cannot stream — it buffers the whole Lambda response
regardless of how it was produced — so streaming requires a Lambda
Function URL with `InvokeMode: RESPONSE_STREAM`, and a Lambda's handler
entry is fixed per function. Hence a second function off the same bundle
(`handler.streamHandler`) with its own transport: Function URL event
type, translator, terminal handler writing to the response stream, and
`createStreamLambdaHandler`.

`streamifyResponse` is applied eagerly, because the runtime inspects the
exported handler for the mark it attaches; a lazy wrap would silently
fall back to buffered responses.

The existing API Gateway translator now drains a streaming body instead
of failing, so a streaming route still works over that transport as one
buffered response.

Auth

Adds `x-webiny-authorization`, read ahead of `Authorization` by both the
AWS and self-hosted extractors. Behind CloudFront with Origin Access
Control, SigV4 occupies `Authorization`, so a viewer bearer token cannot
survive to the origin. Using a separate header keeps the Function URL
private (AWS_IAM + OAC) without depending on that interaction.

Infra (@webiny/project-aws)

`ApiGraphqlStream` (Lambda + Function URL, reusing the graphql IAM role,
300s timeout), a CloudFront OAC, a `/stream/*` cache behavior ordered
first with `compress: false` — compression buffers chunks and defeats
incremental delivery — and an invoke permission scoped to the
distribution ARN.

File Manager

Extracts the AI enrichment logic out of `AiImageEnrichmentTask` into
`Prepare`/`Apply` use cases shared with a new SSE route, so the task and
the route cannot drift. The route resolves everything it can before
opening the stream, so missing file / non-image / no provider / license
come back as real status codes rather than buried in a 200.

Frontend adds `ApiStreamClient` and a generic `readServerSentEvents`
reader, with auth and tenant decorators mirroring the GraphQL client.

Not yet verified against a real deployment: whether CloudFront passes
chunks through unbuffered, and that `handler.streamHandler` resolves in
the built bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The streaming client sends its auth token in `x-webiny-authorization`, but
`SecureHeadersDecorator` never listed that header in
`Access-Control-Allow-Headers`. The preflight returned 204 while failing
the CORS check, so the browser blocked the actual request before sending
it — surfacing as an opaque "Failed to fetch" with no response headers
rather than a 4xx.

Adds a test asserting every custom request header Webiny clients send is
present in the allow-list, so the next one can't slip through the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects found by inspecting the built api artifact before deploying.
The first two would each have broken the response-streaming Lambda; the
third broke every non-Lambda import of the bundle.

1. rspack tree-shook `streamHandler` away

Nothing imports an entry's exports, so the unused one was dropped along
with every module reachable only from it — the bundle exported just
`handler` and contained no streaming code at all. Declaring the entry a
module library marks its exports as the public API and keeps them.
`handler` survived only by accident of being first.

2. The WCP telemetry wrapper re-exported only `handler`

`WcpInjectTelemetryClientAfterBuild` renames the bundle to `_handler.mjs`
and puts a downloaded telemetry wrapper in its place. That wrapper knows
only about `handler`, so `handler.streamHandler` — what the Pulumi config
points the streaming function at — did not exist in the deployed artifact.

The re-export is unwrapped: `streamHandler` carries the marker
`streamifyResponse` attaches and the runtime inspects the exported function
for it, so wrapping it would silently downgrade the function to buffered
responses. It goes through a namespace import rather than a named
re-export because this injection also runs for the self-hosted api build,
whose bundle has no `streamHandler`, and a named re-export of a missing
binding is a hard ESM error that took the whole handler down.

3. The streaming-runtime check tested the wrong thing

`@aws/lambda-invoke-store`, transitive via the AWS SDK, runs
`globalThis.awslambda = globalThis.awslambda || {}` at import time. In
Lambda that preserves the runtime's real global, but everywhere else it
leaves an EMPTY object — so checking the object's presence passed outside
Lambda and `streamifyResponse` then threw at module load, failing the
import of the entire bundle including the buffered handler. Checks for the
function now.

Verified on the built artifacts of both hosting types: AWS exports
`handler` + `streamHandler` (2.83 MB minified, under the 4.5 MB cap),
self-hosted exports `handler` with `streamHandler` undefined, and both
import cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ront

Three defects, all found by measuring a real deployment rather than reading
the code. Response streaming now works end to end on AWS.

1. Missing IAM permission (403 on every request)

CloudFront could not invoke the Function URL at all: Lambda's authorizer
denied every signed request with AccessDeniedException and the function was
never invoked. The OAC-for-Lambda docs require TWO permission statements —
`lambda:InvokeFunctionUrl` AND `lambda:InvokeFunction` — and only the former
was granted.

2. The prelude was never flushed (empty 200, no headers)

The runtime emits the response prelude LAZILY, on the first write to the
stream. A response that writes nothing — a CORS preflight is 204 with no
body — therefore sent no prelude at all, and Lambda substituted a default
200 with `application/octet-stream` and none of the route's headers. It
failed silently: status success, nothing logged, and a direct streaming
invoke returned 0 bytes. Every path now guarantees one write, including a
stream that yields no chunks. Verified against the deployed function: the
same invoke now returns the prelude JSON plus its 8-byte delimiter.

This is what made the preflight "succeed" while the browser still reported
a CORS failure — it arrived header-less.

3. Legacy forwardedValues on the streaming behavior

Replaced with Managed-CachingDisabled plus an origin request policy. The
policy also forwards `Access-Control-Request-Method` and
`Access-Control-Request-Headers`, without which the origin cannot build a
correct preflight response.

Also adds a loud warning when `streamifyResponse` is unavailable inside
Lambda. That state is otherwise invisible: the handler is invoked buffered
and returns header-less empty responses that look like a working stream.

Note for future streaming routes: OAC does not sign the request body, so a
route that POSTs a body needs the client to send `x-amz-content-sha256`.
The enrich route sends no body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@adrians5j
adrians5j marked this pull request as draft August 4, 2026 14:42
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