Skip to content

feat: pass a consumed module's server-streaming methods through to the page - #41

Merged
AntoineToussaint merged 1 commit into
mainfrom
feat/stream-passthrough
Sep 26, 2026
Merged

AntoineToussaint merged 1 commit into
mainfrom
feat/stream-passthrough

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Consumed-module passthrough now serves server-streaming methods. Until now resolvePassthrough refused every streaming method ("only unary methods pass through"), so a page could not follow a module's live state, such as a chat turn's progress. The first consumer is a composed module's turn events.

What changes

  • Page side: a declared server-streaming method is a Connect server-streaming procedure at the same /modules/<as>/<pkg.Service>/<Method> path. connect-es's generated client works unmodified.
  • Authority: unchanged from unary, and still per method. The runtime mints the Work Context for that method's scopes, or forwards the bearer alone for a ViewerBearer module. Both go through the new shared authorize helper that unary now uses too.
  • Allowlist: the declared response fields (Response / WholeResponse) apply to every streamed message.
  • Bounds: both are declared per method on ConsumedMethod, and a declaration is refused at boot when invalid.
    • MaxStreamDuration: default DefaultStreamDuration (5m), at most MaxStreamDurationLimit (30m). When it runs out the stream ends with deadline_exceeded.
    • MaxStreamMessageBytes: default DefaultStreamMessageLimit (1 MiB). A larger message ends the stream with resource_exhausted; it is never truncated.
    • Timeout bounds only the mint and the module's first answer, and MaxResponseBytes is refused on a streaming method.
  • Cancellation: the page disconnecting, or the duration ending, cancels the module request. The stream body is closed, never drained.
  • Refused at boot: client-streaming methods, and stream bounds declared on a unary method or outside their range.
  • Module wire: grpc-gateway's server-streaming form, over the method's google.api.http binding. It is newline-delimited JSON (application/x-ndjson; application/json is accepted), one {"result": <protojson>} or {"error": {"code": <int>, "message": "…"}} per line. Blank lines are ignored.
    • An error line maps to that Connect code.
    • A non-2xx answer before the stream starts is relayed like a unary refusal.
    • Any other content type is unavailable.
  • New exported API: Gateway.TranscodedStream exposes the same call to a solution's own handlers.
  • Interface artifact: a streamed operation is marked (Operation.Streaming, x-streaming: server), and its behaviour text states the bounds and that the mask applies per message.
  • Docs: the README ("Streamed methods") and the package doc describe the module contract.

Compatibility: additive. Two new fields on ConsumedMethod and Operation, new constants and Gateway.TranscodedStream; nothing existing changes shape. The only behaviour that changes is that a server-streaming declaration boots instead of being refused.

Classification

Fix, at the owner. The refusal was this package's own limitation, and it is lifted here rather than worked around in each solution.

Verified

The repo's gate (go mod verify, go test -race ./..., go vet ./..., gofmt) passes. New hermetic tests (stream_test.go) cover:

  • messages streamed with a per-message mask and per-method minted authority;
  • bearer-only forwarding;
  • an error line mapped to its Connect code;
  • a refusal before the stream;
  • an oversized message → resource_exhausted, never truncated;
  • the declared duration → deadline_exceeded, with the module request cancelled;
  • a page disconnect cancelling the module request;
  • a non-stream content type refused;
  • boot-time refusals;
  • the interface artifact.

A mutation check (removing the mask) fails the mask test. The existing unary tests are unchanged and pass.

Not verified

  • No real composition: the suite is hermetic (httptest counterparts only), and no real module has served the stream through a real host yet. The first live consumer is a composed module's ObserveTurn.
  • Host frontend proxy, for a Connect stream (module-saas-starter app/api/solutions/[id]/proxy/[...path]/route.ts):
    • It returns upstream.body unbuffered.
    • It sets cache-control: no-store, no-transform and x-accel-buffering: no only for text/event-stream. A Connect stream is application/connect+json, so it gets neither header. An nginx-style ingress could buffer it, or something on the path could compress it.
    • Next's compress is left at its default (on). I did not verify whether that buffers a streamed route-handler response.
    • The fix belongs in saas-starter: apply the same two headers to application/connect+json / application/connect+proto. SSE is not needed.

🤖 Generated with Claude Code

…e page

The passthrough refused every streaming method ("only unary methods pass
through"), so a page could not follow a module's live state. A
server-streaming method is now served as a Connect server-streaming
procedure at the same /modules/<as>/<pkg.Service>/<Method> path, under
exactly the unary rules:

- the authority minted for that method (or the bearer alone, for a
  ViewerBearer module);
- the declared response fields applied to every streamed message.

Both bounds are declared per method: MaxStreamDuration (default 5 min, at
most 30 min; the stream then ends with deadline_exceeded) and
MaxStreamMessageBytes (default 1 MiB; a larger message ends the stream with
resource_exhausted and is never truncated). The page disconnecting cancels
the module request. Client-streaming methods, and stream bounds on a unary
method or out of range, are refused at boot.

A module answers a streamed method over its google.api.http binding with
grpc-gateway's server-streaming wire: newline-delimited JSON,
{"result": …} or {"error": {"code", "message"}} per line
(application/x-ndjson, or application/json). Gateway.TranscodedStream is the
same call for a solution handler. The interface artifact marks a streamed
operation (x-streaming: server).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@AntoineToussaint
AntoineToussaint merged commit cce2559 into main Sep 26, 2026
1 check passed
@antoinetoussaint-byte
antoinetoussaint-byte deleted the feat/stream-passthrough branch October 25, 2026 17:04
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.

2 participants