Skip to content

AdvancedStreaming

Streaming

Receive responses as ReadableStreams for SSE, large downloads, and progressive rendering.

3 min readboth modes

Overview

By default, Courier parses the response body as JSON or text. When a call asks for stream: true, Courier returns the raw ReadableStream from response.body instead of parsing it.

This is useful for Server-Sent Events (SSE), large file downloads, and any scenario where you want to process data incrementally rather than buffering the entire response.

Defining stream endpoints

Streaming is chosen by the caller, not by the endpoint: the endpoint says where to go, the call says how the body should arrive. Declare the response as a ReadableStream and let callers ask for it:

api/events.ts
import { courier, defineGroup } from "@noverax/courier/define"

const { endpoint } = courier()

export const events = defineGroup({
  prefix: "events",
  endpoints: {
    subscribe: endpoint.get(":channel/stream").variables<{ channel: string }>().response<ReadableStream>(),
  },
})

export const exports = defineGroup({
  prefix: "exports",
  endpoints: {
    download: endpoint.get(":id/download").variables<{ id: string }>().response<ReadableStream>(),
  },
})

Ask for the stream at the call site:

ts
const response = await caller.events.with({ stream: true }).subscribe({ channel: "builds" })

What a streamed call resolves to

A streamed call resolves to the unread body — a ReadableStream<Uint8Array>. Ask for .raw as well and it resolves to the response around it, { status, headers, data }, with the stream in data. Both shapes are the same in direct and proxy mode, and the types follow the option: .with({ stream: true }) changes what the call is typed to return.

app/exports/[id]/page.tsx
import { caller } from "@/lib/caller"

export default async function ExportPage({ params }: { params: { id: string } }) {
  const body = await caller.exports.with({ stream: true }).download({ id: params.id })
  // body: ReadableStream<Uint8Array>

  const response = await caller.exports.raw.with({ stream: true }).download({ id: params.id })
  // response.status, response.headers.get("content-type"), response.data: ReadableStream<Uint8Array>
}

In proxy mode the stream is not buffered: data flows from your backend through the proxy to the browser as it arrives, with the content type, and with the cookies the backend set where the endpoint names set-cookie. It is served from your application's origin, so it also carries Content-Security-Policy: default-src 'none'; sandbox and X-Content-Type-Options: nosniff: a streamed URL opened as a page cannot run a script the backend sent as your application. A caller reading the stream with fetch is unaffected.

A response schema describes the value a caller reads, not the bytes, so it is not applied to a streamed call. That makes it a question of who asks. A server-side caller may stream any endpoint. A browser may not stream one that declares a response schema: the schema is what keeps the fields it strips away from the page, and a stream would hand over the backend's body before the schema has seen it. The proxy answers such a call with 400. Leave the schema off an endpoint the browser is meant to stream, as the ReadableStream endpoints above do — .response<ReadableStream>() names a type and declares no schema.

Consuming streams in client components

Use the standard ReadableStream API to consume streamed responses. The getReader() method gives you a reader that yields chunks as they arrive:

components/event-stream.tsx
"use client"

import { useEffect, useState } from "react"

import { caller } from "@/lib/caller"

export function EventStream({ channel }: { channel: string }) {
  const [events, setEvents] = useState<string[]>([])

  useEffect(() => {
    let cancelled = false

    async function consume() {
      const body = await caller.events.with({ stream: true }).subscribe({ channel })
      const reader = body.getReader()
      const decoder = new TextDecoder()

      while (!cancelled) {
        const { done, value } = await reader.read()
        if (done) break
        const text = decoder.decode(value, { stream: true })
        setEvents((prev) => [...prev, text])
      }

      reader.releaseLock()
    }

    consume()
    return () => { cancelled = true }
  }, [channel])

  return (
    <ul>
      {events.map((event, i) => (
        <li key={i}>{event}</li>
      ))}
    </ul>
  )
}

SSE pattern

Server-Sent Events follow a specific text format (event: and data: fields separated by double newlines). Here's a complete SSE consumer with event parsing:

components/sse-consumer.tsx
"use client"

import { useEffect, useState } from "react"

import { caller } from "@/lib/caller"

type SSEEvent = { event: string; data: string }

function parseSSE(chunk: string): SSEEvent[] {
  return chunk
    .split("\n\n")
    .filter(Boolean)
    .map((block) => {
      const lines = block.split("\n")
      const event = lines.find((l) => l.startsWith("event:"))?.slice(6).trim() ?? "message"
      const data = lines.find((l) => l.startsWith("data:"))?.slice(5).trim() ?? ""
      return { event, data }
    })
}

export function SSEConsumer({ channel }: { channel: string }) {
  const [messages, setMessages] = useState<SSEEvent[]>([])

  useEffect(() => {
    let cancelled = false

    async function consume() {
      const body = await caller.events.with({ stream: true }).subscribe({ channel })
      const reader = body.getReader()
      const decoder = new TextDecoder()

      while (!cancelled) {
        const { done, value } = await reader.read()
        if (done) break
        const text = decoder.decode(value, { stream: true })
        const events = parseSSE(text)
        setMessages((prev) => [...prev, ...events])
      }

      reader.releaseLock()
    }

    consume()
    return () => { cancelled = true }
  }, [channel])

  return (
    <ul>
      {messages.map((msg, i) => (
        <li key={i}>
          <strong>{msg.event}:</strong> {msg.data}
        </li>
      ))}
    </ul>
  )
}

Next steps

  • Raw Data Mode — access status codes and headers alongside response data
  • Options — all per-request options, stream and timeout among them
  • Middleware — intercept and transform requests with composable middleware