feat(event-handler): HTTP response streaming on Lambda and self-hosted - #5533
Draft
adrians5j wants to merge 4 commits into
Draft
feat(event-handler): HTTP response streaming on Lambda and self-hosted#5533adrians5j wants to merge 4 commits into
adrians5j wants to merge 4 commits into
Conversation
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
marked this pull request as draft
August 4, 2026 14:42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.bodywas alreadyany, so no existing route changes. Transports that can stream write chunks as they are produced; transports that cannot callcollect().Duck-typing
Symbol.asyncIteratorwas rejected deliberately: a plain object body could satisfy it accidentally, andReadableStream's async-iterator support isn't in the DOM types.Self-hosted
createServerHandlerflushes 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 throwsERR_HTTP_HEADERS_SENTonce 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 ofAuthorizationby both the AWS and self-hosted extractors. Behind CloudFront with Origin Access Control, SigV4 occupiesAuthorization, 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 withcompress: false— CloudFront compression buffers chunks and defeats incremental delivery.File Manager
The AI enrichment logic is extracted out of
AiImageEnrichmentTaskintoPrepare/Applyuse 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
ApiStreamClientand a genericreadServerSentEventsreader, with auth and tenant decorators mirroring the GraphQL client.GraphQLClientcouldn'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:
streamHandleraway. Nothing imports an entry's exports, so the unused one went along with every module reachable only from it.handlersurvived by accident of being first. Fixed by declaring the entry a module library.handler. It renames the bundle to_handler.mjsand substitutes a downloaded wrapper, sohandler.streamHandlerdidn't exist in the deployed artifact. Re-exported unwrapped (thestreamifyResponsemarker 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).lambda:InvokeFunctionUrlandlambda:InvokeFunction; with only the first, Lambda denied every signed CloudFront request and the function was never invoked.application/octet-streamwith 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, andai-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
_onBeforeFirstWrite, which isn't public API. Tests can only assert we callwriteonce; if AWS changes that hook it regresses to the silent empty-200.api.webiny.com/clients/latest.mjs. If that file's shape changes, this breaks.library: { type: "module" }is in sharedbuild-toolsand affects every function bundle on both hosting types. Both were verified to build and import, but it has the widest blast radius here./stream/*route is a new public surface; authorization leans on the WCP gate plusUpdateFileUseCase. No route-level permission checks were added.x-amz-content-sha256. The enrich route sends none.🤖 Generated with Claude Code