JSON Lines Streaming
Overview
Vovk.ts supports the JSON Lines format, which answers one request with many responses. It is an output type with its own validation field, iteration. The response has the application/jsonl content type when the client sends an Accept: application/jsonl header. Otherwise it is text/plain, so a browser shows it when you open the endpoint URL.
Use JSON Lines for:
- A type-safe alternative to Server-Sent Events (SSE) for streaming data to clients.
- Long-running operations that produce results over time, such as LLM completions or database polling.
- Progressive data loading, which sends partial results as they become ready.
The response size isn’t known in advance, so a JSON Lines response can’t be compressed with Gzip, Brotli or other algorithms. Keep this in mind for large responses.
Creating a JSON Lines Generator Procedure
To stream JSON Lines, write the handler as a generator or an async generator. Each yielded value is serialized to JSON and sent as its own line, so the client gets each item after JSON: a Date arrives as a string. .fn() yields the values as they are.
import { z } from 'zod';
import { procedure, prefix, post, type VovkIteration } from 'vovk';
@prefix('stream')
export default class StreamController {
@post('completions')
static getJSONLines = procedure({
// ...
iteration: z.object({
message: z.string(),
}),
}).handle(async function*() {
const tokens: VovkIteration<typeof StreamController.getJSONLines>[] = [
{ message: 'Hello,' },
{ message: ' World' },
{ message: ' from' },
{ message: ' Stream' },
{ message: '!' },
];
for (const token of tokens) {
await new Promise((resolve) => setTimeout(resolve, 300));
yield token;
}
});
}To stream from a service, delegate to it with yield*:
import { z } from 'zod';
import { procedure, prefix, post } from 'vovk';
import StreamService from './stream-service';
@prefix('stream')
export default class StreamController {
@post('completions')
static getJSONLines = procedure({
// ...
iteration: z.object({
message: z.string(),
}),
}).handle(async function* () {
yield* StreamService.getJSONLines();
});
}import type { VovkIteration } from 'vovk';
import type StreamController from './stream-controller';
export default class StreamService {
static async *getJSONLines() {
const tokens: VovkIteration<typeof StreamController.getJSONLines>[] = [
{ message: 'Hello,' },
{ message: ' World' },
{ message: ' from' },
{ message: ' Stream' },
{ message: '!' },
];
for (const token of tokens) {
await new Promise((resolve) => setTimeout(resolve, 300));
yield token;
}
}
}On the client, read each line as it arrives with disposable async iterators :
import { StreamRPC } from '@/client';
using stream = await StreamRPC.getJSONLines();
for await (const { message } of stream) {
console.log('Received message:', message);
}Besides Symbol.asyncIterator, Symbol.dispose and Symbol.asyncDispose, the iterable (stream above) has:
status: the HTTP response status, such as 200 for OK or 404 for Not Found.asPromise: returns a promise that resolves with an array of all items when the stream ends. Every call gets the same items.onIterate: registers a callback for each item.abortController: anAbortControllerthat aborts the stream.abortController.abort()makes the stream reader throw anAbortError, which the client finds in the error’scauseproperty.abortSilently: aborts the stream without an error on the stream reader, to stop reading it quietly.
With using, the stream is aborted with stream.abortSilently('Stream disposed') when it goes out of scope.
The client reads the response only as fast as the iteration takes items, so a slow consumer slows the stream down instead of buffering it. An item is kept until every running iteration has passed it. Iterations that run at the same time each get all items. An iteration that starts later begins at the oldest kept item, and iterating a consumed stream again yields nothing. After a break, the items already read stay for a later iteration or asPromise(). A line that isn’t JSON, such as a last line the connection cut short, ends the iteration with an error.
console.log('Response status:', stream.status);
stream.onIterate((item) => {
console.log('Iterated item:', item);
});
if (someCondition) {
stream.abortSilently();
}
console.log('All messages:', await stream.asPromise());OpenAI Chat Example
This procedure streams OpenAI’s chat completions by delegating to them:
import { post, prefix, operation, type VovkRequest } from 'vovk';
import OpenAI from 'openai';
@prefix('openai')
export default class OpenAiController {
@operation({
summary: 'Create a chat completion',
})
@post('chat')
static async *createChatCompletion(
req: VovkRequest<{ messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] }>
) {
const { messages } = await req.json();
const openai = new OpenAI();
yield* await openai.chat.completions.create({
messages,
model: 'gpt-5-nano',
stream: true,
});
}
}On the client, read the streamed completion:
// ...
using completion = await OpenAiRPC.createChatCompletion({
body: { messages: [...messages, userMessage] },
});
for await (const part of completion) {
// ...
}View full example on examples.vovk.dev »
JSONLinesResponder Class
JSONLinesResponder is the lower-level API behind generator handlers. It gives you more control: you send the lines yourself. It creates the ReadableStream that is the response body.
const responder = new JSONLinesResponder<IterationType>(req);const responder = new JSONLinesResponder<IterationType>(req, ({ readableStream, headers }) => new Response(readableStream, { headers }));The constructor takes two optional parameters:
request?: Request | null: the incoming request. When given, the responder checks itsAccept: application/jsonlheader and setsContent-Type: application/jsonlin theheadersof the response. Without the request or the header, the content type istext/plain. The RPC client streams only a JSON Lines response and returns any otherResponseas is, so pass the request when the client reads the stream. In a procedure withiteration, the request also lets the responder validate a line sent before the handler returns it.getResponse?: (responder: JSONLinesResponder<T>) => Response: builds a customResponse, for example with your own headers or other response options. Without it, the responder creates aResponsewith the default headers.
The responder has these members:
send(item: T): Promise<void>: sends a JSON line to the client. The item is validated (when the procedure hasiteration, only the first item, unlessvalidateEachIteration: trueis set), serialized to JSON and followed by a newline. Lines go out in call order, also whensend()is not awaited. A send that fails, on iteration validation or with an item JSON can’t serialize, ends the stream asthrow()does, and the segment’sonErrorgets the error.close(): Promise<void>: closes the response stream once the lines sent before it are out. A line sent afterclose()is dropped.throw(err: Error): Promise<void>: sends an error line after the lines sent before it, and closes the stream; a line sent afterthrow()is dropped. The error line is{"isError":true,"reason":"…"}, plus thestatusCodeof anHttpException, which is 500 for a status that is not a number from 200 to 599, as on a JSON response. The client rethrows it as anHttpExceptionwith that status. The error ofnotFound(),forbidden()orunauthorized()fromnext/navigation, which Next.js can no longer answer once the stream started, sends its status: 404, 403 or 401. In production, an error that is not anHttpException, or is one with status 0, which a client throws for a call that got no response, reads “Internal server error”.isClosed: boolean: whether the stream is closed, byclose()orthrow()(from the moment either is called), by a failed send, or by the client going away.send()waits while the client reads slower than the handler writes, and a generator handler is stopped, with itsfinallyrun, when the client disconnects or an item fails.response: Response: theResponsethat the Next.js route handler returns.headers: Record<string, string>: thecontent-typeof the response.readableStream: ReadableStream<Uint8Array>: the stream used as the response body.
With JSONLinesResponder, the service method is a regular function, not a generator. It takes the responder and sends the lines through it.
import type { JSONLinesResponder, VovkIteration } from 'vovk';
import type StreamController from './stream-controller';
export type Token = VovkIteration<typeof StreamController.streamTokens>
export default class StreamService {
static async streamTokens(responder: JSONLinesResponder<Token>) {
const tokens: Token[] = [{ message: 'Hello,' }, { message: ' World' }, { message: '!' }];
for (const token of tokens) {
await new Promise((resolve) => setTimeout(resolve, 300));
await responder.send(token);
}
responder.close();
}
}The handler returns the responder. The streaming runs in a promise that starts before return and isn’t awaited.
import { z } from 'zod';
import { prefix, get, procedure, JSONLinesResponder } from 'vovk';
import StreamService, { type Token } from './stream-service';
@prefix('stream')
export default class StreamController {
@get('tokens')
static streamTokens = procedure({
iteration: z.object({ message: z.string() }),
}).handle(async (req) => {
const responder = new JSONLinesResponder<Token>(req);
void StreamService.streamTokens(responder);
return responder;
});
}The iteration schema gives Token its type, in the service and in the RPC client.
To end the stream with an error, call throw. The client rethrows the error:
await responder.throw(new Error('Stream error'));