Docs

Streaming

Nifra streams HTML as it renders - the shell goes out first, slow data fills in. It's a Web ReadableStream, so it works on Bun, Node, Deno, and the edge (workerd).

Suspense & defer()

Wrap slow data in defer() in the loader and render it through <Await> (a Suspense boundary). The client receives the shell + a streamed resolution, then hydrates - no waterfall, no blank screen.

TS
// routes/index.backend.ts - send the page shell immediately; stream the slow part when it resolves.
import { t } from "@nifrajs/schema"
import { defer } from "@nifrajs/web"
import type { Route } from "./+types/index"

declare function loadFeed(userId: string): Promise<string[]>

export const loaderOutput = t.object({
  name: t.string(),                                   // awaited - in the shell
  feed: t.deferred(t.array(t.string())),              // deferred - streamed later
})

export async function loader({ api }: Route.LoaderArgs) {
  const user = await api.users({ id: "7" }).get()
  return {
    name: user.ok ? user.data.name : "",
    feed: defer(loadFeed("7")),
  }
}
TS
// routes/index.tsx
import type { Route } from "./+types/index"

export default function Page({ data }: Route.ComponentProps) {
  return (
    <>
      <h1>{data.name}</h1>
      <Await resolve={data.feed} fallback={<p>Loading feed…</p>}>
        {(feed) => <Feed items={feed} />}
      </Await>
    </>
  )
}

The same defer() works in actions and across client-side soft navigations (an NDJSON stream settles the deferred values), and it's framework-agnostic - React <Suspense> and Solid's streaming both drive it from one core.

Server-Sent Events

For server push - live feeds, progress, notifications - sse(c, run) returns a text/event-stream response a handler returns directly. Push frames with stream.send({}); the connection stays open until run resolves, you call stream.close(), or the client disconnects (stream.signal). It's a Web ReadableStream too, so it runs on Bun, Node, Deno, and the edge - no new Function, no per-runtime API.

TS
import { server } from "@nifrajs/core/server"
import { sse } from "@nifrajs/core/sse"

const app = server()
// Your pub-sub of choice - subscribe returns an unsubscribe function.
declare const notifications: { subscribe(on: (n: { id: string }) => void): () => void }

// A live feed - push events until the client disconnects.
app.get("/notifications", (c) =>
  sse(c, (stream) => {
    const off = notifications.subscribe((n) =>
      stream.send({ event: "notification", id: n.id, data: JSON.stringify(n) }),
    )
    // Keep the connection open until the client leaves, then tear down.
    return new Promise<void>((resolve) =>
      stream.signal.addEventListener("abort", () => { off(); resolve() }, { once: true }),
    )
  }, { keepAlive: 15_000 }),
)

event:, id:, and retry: are supported (and CR/LF is stripped from event/id to prevent frame injection); multi-line data is split into multiple data: lines per the spec; and keepAlive emits comment pings so idle proxies don't drop the connection.

A client that stops reading is dropped once it has maxBufferedBytes (default 1 MiB) of events unread: stream.signal aborts with sse_client_too_slow, the queued frames are released, and later sends do nothing, so one stalled reader cannot hold server memory without limit. Raise it for a burst larger than that sent before the client can read, or pass Infinity to turn the limit off.

Typed SSE routes

app.sse(path, schema, run) declares the event payload with sse, so stream.send() is type-checked and the typed client grows .subscribe(onEvent) with the same event type. A route whose schema declares a body is a POST stream: the body validates as on any POST, and .subscribe(onEvent, { body }) sends it. A GET stream reconnects after a drop and resumes with Last-Event-ID; a POST stream does not reconnect unless you pass reconnect: true, since a reconnect sends the body again. The client's onRequest hook runs before each connect, so a refreshed token reaches the stream.

TS
import { client } from "@nifrajs/client"
import { server } from "@nifrajs/core/server"
import { streaming } from "@nifrajs/core/sse"
import { t } from "@nifrajs/schema"

declare function generate(prompt: string): AsyncIterable<string>

export const app = server()
  .use(streaming())
  // A GET stream: the event payload is contracted by `sse`.
  .sse("/feed", { sse: t.object({ id: t.integer(), title: t.string() }) }, (_c, stream) => {
    stream.send({ id: 1, title: "hello" })
    stream.close()
  })
  // A body makes it a POST stream, validated like any POST: a chat that posts its prompt.
  .sse(
    "/chat",
    { body: t.object({ prompt: t.string() }), sse: t.object({ token: t.string() }) },
    async (c, stream) => {
      for await (const token of generate(c.body.prompt)) stream.send({ token })
      stream.close()
    },
  )

const api = client<typeof app>("https://example.com")
const feed = api.feed.subscribe((post) => console.log(post.title))
api.chat.subscribe((event) => console.log(event.token), { body: { prompt: "Hi" } })
feed.close()