zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Server framework

Streaming FilesSupported

@zmdb/web responses can carry text, bytes or a ReadableStream. Both adapters honour the body kind; the Node adapter respects write() backpressure and cancels the source when the client disconnects.

export type ResponseBody =
  | { readonly kind: 'text'; readonly value: string }
  | { readonly kind: 'bytes'; readonly value: Uint8Array<ArrayBuffer> }
  | {
      readonly kind: 'stream';
      readonly value: ReadableStream<Uint8Array<ArrayBuffer>>;
      readonly length: number | undefined;
    };

Migrating from string response bodies#

Handler code that uses json(), text(), or respond() does not change; those factories now construct the tagged text arm internally. A plain handler return value also keeps its existing meaning and is serialized as 200 application/json.

Only code that reads or constructs WebResponse.body directly must account for the union:

return respond({
  body: html,
  headers: { 'content-type': 'text/html; charset=utf-8' },
});

respond() deliberately remains string-only. Use bytes() for an in-memory binary body, stream() for an application-owned stream, and file() for a trusted file path. Consumers that need text in tests can call await bodyText(response); doing so drains a stream.

Stream from a handler#

Use stream() for an application-owned web stream:

import { stream } from '@zmdb/web';

@Get('/events')
events() {
  return stream(eventSource(), {
    headers: { 'content-type': 'text/event-stream' },
    onError: error => this.log.error({ error }),
  });
}

onError is required. Once headers and the first chunk have been sent, an HTTP status can no longer report a failure. The Node adapter destroys the connection instead of ending a truncated body cleanly; the callback is the server-side record of what happened.

Pass length only when it is known. The adapters set content-length from that value, and body consumption fails if the stream produces a different byte count. Without it, the runtime chooses streaming framing.

Send bytes#

import { bytes } from '@zmdb/web';

return bytes(png, {
  headers: { 'content-type': 'image/png' },
});

The byte length overrides a caller-supplied content-length. Framing belongs to the adapter, so a handler-supplied transfer-encoding is removed.

Send a known file#

import { file } from '@zmdb/web';

@Get('/files/:id')
async download(ctx: Ctx<{ id: string }>) {
  const record = await this.repo.findById(Number(ctx.params.id));
  return file(record.path, {
    contentType: record.contentType,
    headers: {
      'content-disposition': `attachment; filename="${encodeURIComponent(record.name)}"`,
    },
    onError: error => this.log.error({ id: record.id, error }),
  });
}

file() opens a path the application already trusts, measures it, and streams it without materialising the whole file. It does not confine user input to a root, decode URL paths, implement ranges or generate cache validators. Those security and HTTP policies belong to the shipped static-file handler.

For large public downloads, a presigned object-storage URL is still usually the better architecture: the application signs and records the request while the CDN serves the bytes.

Cancellation and bodyless responses#

Request-body limits#

Both adapters default maxBodyBytes to 1 MiB:

createServer(toNodeHandler(router, { maxBodyBytes: 8 * 1024 * 1024 }));
const fetch = toFetchHandler(router, { maxBodyBytes: 8 * 1024 * 1024 });

The value must be a positive safe integer. An oversized body receives 413 and is not dispatched. JSON and text remain decoded values; another content type reaches WebRequest.rawBody as exact bytes.

---

See also: Static Files · Compression · Raw Body