Skip to content

Streaming support for smithy4s-ndjson - #1

Merged
kubukoz merged 3 commits into
mainfrom
streaming-ndjson
Aug 19, 2026
Merged

Streaming support for smithy4s-ndjson#1
kubukoz merged 3 commits into
mainfrom
streaming-ndjson

Conversation

@kubukoz

@kubukoz kubukoz commented Aug 19, 2026

Copy link
Copy Markdown
Member

Adds codegen support for org.polyvariant.ndjson#ndjsonRestJson, so operations can stream their request body, their response body, or both.

The model

Smithy restricts @streaming to blob/union, and the protocol requires such a member to carry @httpPayload. That gives exactly two framings, applied identically in both directions:

Smithy Wire TypeScript
@streaming blob application/octet-stream AsyncIterable<Uint8Array>
@streaming union application/x-ndjson AsyncIterable<TheUnion>

All four in/out combinations fall out of that.

What it looks like

A streamed member is surfaced as an AsyncIterable on the generated type itself, so signatures stay ordinary — no Omit<…> at call sites:

const feed = new FeedClient(transport, streamTransport)

const { events } = await feed.watch({ id })
for await (const event of events) { /* typed as FeedEvent */ }

await feed.upload({ id, body: chunks /* AsyncIterable<Uint8Array> */ })

The member is left out of the zod object (validating it would mean consuming the stream), and ndjson elements are schema-checked one at a time as they are pulled — a bad element throws StreamDecodeError at that element instead of truncating the stream.

Framing is the transport's job: StreamTransport.requestStream receives explicit requestStreamEncoding / responseStreamEncoding rather than inferring from a content type. Mocks mirror the client, so a story implements a streaming operation as an async generator.

Only the protocol module is a dependency — the codegen keys off @streaming members, so nothing Scala-specific from smithy4s-ndjson is involved.

Backward compatibility

Verified by diffing a non-streaming model's output before and after: byte-for-byte identical. Getting there caught a member-ordering regression that the substring tests missed.

Typechecking the generated TypeScript

The Scala tests assert on substrings, which cannot catch a type error. This adds a nix flake that runs the real tsc over a committed sample (typecheck/ — a model covering every construct, plus a consumer-side usage.ts exercising clients, streams and mocks as a caller would), under strict + erasableSyntaxOnly.

It immediately earned its keep, catching two pre-existing bugs unrelated to streaming:

  • a @httpQuery timestamp was cast to string | number | boolean, which a Date does not satisfy — now serialised per target shape;
  • a structure whose only member streams intersected with z.infer of an empty z.object (Record<string, never>), making every property never.

CI installs nix, runs nix flake check, and fails if the committed sample has drifted from the model.

Testing

  • 18 unit tests (was 16)
  • sbtPlugin/scripted, extended with a streaming service
  • nix flake check — verified it fails on an introduced type error, not just that it passes

🤖 Generated with Claude Code

https://claude.ai/code/session_01MGq82T8PTfFTS6s2tH5Xs9

Operations of an `org.polyvariant.ndjson#ndjsonRestJson` service may stream
their request body, their response body, or both. Smithy restricts
`@streaming` to `blob`/`union` and the protocol requires such a member to
carry `@httpPayload`, so there are exactly two framings, applied identically
in both directions: a blob is the body verbatim, a union is one JSON value
per line.

A streamed member is surfaced as an `AsyncIterable` on the generated type
itself, so signatures stay ordinary — `watch(input: WatchInput):
Promise<WatchOutput>`, with `WatchOutput.events` an
`AsyncIterable<WatchEvent>`. The member is left out of the zod object
(validating it would mean consuming the stream) and ndjson elements are
checked one at a time as they are pulled.

Framing is the transport's job: `StreamTransport.requestStream` receives
explicit `requestStreamEncoding` / `responseStreamEncoding` rather than
inferring from a content type. Services with streaming operations take both
halves of the transport; a model with no streaming emits byte-for-byte what
it did before.

Mocks mirror the client, so a story implements a streaming operation as an
async generator.

Only the protocol module is needed — the codegen keys off `@streaming`
members, so nothing scala-specific from smithy4s-ndjson is involved.

Alongside it, a nix flake that type-checks the emitted TypeScript with the
real `tsc`. The Scala tests assert on substrings, which cannot catch a type
error; running tsc over a committed sample (typecheck/, covering every
construct, plus a consumer-side usage file) caught several, including two
pre-existing bugs unrelated to streaming:

- a `@httpQuery` timestamp was cast to `string | number | boolean`, which a
  `Date` does not satisfy — it is now serialised per target shape;
- a structure whose only member streams intersected with `z.infer` of an
  empty `z.object`, i.e. `Record<string, never>`, making every property
  `never`.

CI installs nix, runs the check, and fails if the committed sample has
drifted from the model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGq82T8PTfFTS6s2tH5Xs9
kubukoz and others added 2 commits August 19, 2026 16:21
`scalafmtAll` doesn't cover .sbt files — `scalafmtSbtCheck` does, and it was
failing on the new task definitions.

`tsCodegenSampleCheck` is defined on the root build, but the CI step ran it
after `project rootJVM`, where it isn't in scope ("Not a valid command").
Select `project /` first, as the surrounding scalafmtSbtCheck step already
does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGq82T8PTfFTS6s2tH5Xs9
The assertion predated the empty-object fix: in that model `events` was
WatchOutput's only member, so the type is now the stream alone rather than an
intersection with `z.infer` of an empty `z.object`.

Give Watch a bound `session` header as well, so the scripted test covers the
intersection case (stream + other members) rather than only stream-alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGq82T8PTfFTS6s2tH5Xs9
@kubukoz
kubukoz merged commit a457338 into main Aug 19, 2026
10 checks passed
@kubukoz
kubukoz deleted the streaming-ndjson branch August 19, 2026 14:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant