Stream a file through the process
How-to. Take an upload's bytes as they arrive and send a download's bytes as they are read, when the application must see them on the way — to encrypt, to hash, to refuse. For the ports, see
@btravstack/storageandHttpHandler.
When the application need not see the bytes, it should not carry them: Upload a file hands the client a presigned URL instead. Stream through the process when it must.
1. An answerer of your own
A contract route can stream a file when its OpenAPI metadata says so: Stream a binary body declares requestBodyHint: "octet-stream", and streamBodyLimit bounds it. Any other oRPC procedure reads a request body that declares its length into a File in memory, under bodyLimit (1 MiB by default). For a route outside the contract, the runtime underneath reads no body at all: a bare HttpAnswerer mounted beside the router gets the request as it arrives.
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
const CEILING = 1_073_741_824;
/** Passes the bytes through, and errors the stream once more than `ceiling` have passed. */
const capped = (ceiling: number) => {
let seen = 0;
return new TransformStream<Uint8Array, Uint8Array>({
transform: (chunk, controller) => {
seen += chunk.byteLength;
if (seen > ceiling) controller.error(new RangeError("the body is over the ceiling"));
else controller.enqueue(chunk);
},
});
};
export const content = Provider.member(HttpHandler)({
inject: { store: Storage },
sync: ({ store }) => ({
prefix: "/content",
handle: async (request, response) => {
const key = authorizedKey(request);
if (key === undefined) return;
if (request.method === "PUT") {
if (Number(request.headers["content-length"] ?? 0) > CEILING) {
response.writeHead(413).end();
return;
}
const body = (Readable.toWeb(request) as ReadableStream<Uint8Array>).pipeThrough(
capped(CEILING),
);
const written = await store
.putStream(key, body, {
contentType: request.headers["content-type"] ?? "application/octet-stream",
})
.map(() => 204)
.recoverErrCases((matcher) =>
matcher
.with(P.tag("StorageUnavailable"), P.tag("StreamPoolFull"), () => 503)
.with(P.tag("StreamInterrupted"), ({ cause }) =>
cause instanceof RangeError ? 413 : 400,
)
.with(P.tag("InvalidStorageOptions"), P.tag("ChecksumMismatch"), () => 400),
);
response.writeHead(written.getOr(500)).end();
return;
}
if (request.method !== "GET") {
response.writeHead(405, { allow: "GET, PUT" }).end();
return;
}
const read = await store.getStream(key);
if (!read.isOk()) {
response.writeHead(read.isErr() && read.error._tag === "ObjectNotFound" ? 404 : 503).end();
return;
}
const { stream, contentType, size } = read.value;
response.writeHead(200, {
"content-type": contentType,
...(size === undefined ? {} : { "content-length": size }),
});
await pipeline(Readable.fromWeb(stream as never), response);
},
}),
});Provide it beside the router — HttpModule's provides takes it — and the runtime routes /content and everything under it here, by the longest prefix. An answerer that returns without writing gets the runtime's 404.
2. What it owes for itself
- Authentication. A route outside a contract carries no marker, so it is public until the answerer checks something.
authorizedKeystands for that check: a session, a signed grant in the URL, whatever decides which key this request may touch. - A ceiling.
bodyLimit,streamBodyLimitand their variables bound what oRPC, fragments, GraphQL and MCP read; nothing bounds this body. The answerer above refuses a declaredcontent-lengthover its ceiling before reading, and counts the bytes as they pass, since a chunked body declares none:cappederrors the stream past the ceiling, the write ends withStreamInterruptedcarrying that error, and nothing is stored. Keep it well under the store's own ceiling for a streamed object. - Time.
HTTP_REQUEST_TIMEOUT_MS(default five minutes) bounds the whole request, body included. A large upload over a slow link needs a deployment that raises it. - A share of the store's sockets. See below: the adapter bounds how many transfers run at once, and the ingress bounds who may hold them.
3. What a failure does
- A client that hangs up mid-upload errors the request stream, so
putStreamanswersStreamInterruptedand the multipart upload is aborted. No partial object is ever visible. - A store that breaks off mid-download errors the stream,
pipelinerejects, and the runtime destroys a response whose headers are already out. The client sees a reset connection, never a clean end of a short body. - The drain. A transfer still in flight when
drainTimeoutMsruns out is abandoned with its unit: its signal aborts and its connection is closed, so an upload ends as a hang-up does. Parts whose abort the exiting process never sent are not an object either; a bucket lifecycle rule for incomplete multipart uploads reaps them. Transfers that must outlive a deploy belong to a client that resumes, or to a presigned URL.
4. Size the stream pool
s3Storage() keeps two socket pools. getStream, putStream and putStreamIfAbsent use one of their own, STORAGE_S3_STREAM_MAX_SOCKETS sockets (default 50, the AWS SDK's own pool size); every other call shares the other, STORAGE_S3_MAX_SOCKETS (also 50). A stream holds its socket from the call until it closes, read to its end, cancelled or broken off, and a streamed write until it settles. Fifty clients reading slowly therefore hold the stream pool, never the sockets a head, a put or the health check needs.
One transfer more than the pool holds is refused at once, with StreamPoolFull naming the bound (maxSockets); a write's stream is cancelled. Answer 503, as the sample does, with a Retry-After if clients honour it, so the transfer is retried once a slot is free or on a replica with room. The adapter refuses rather than queues: a queued transfer would wait for a reader elsewhere to finish, which nothing in this process bounds, so the request would hang instead of being shed. Each refusal is counted as result: "saturated" on btravstack.storage.operations, the rate to alert on.
Size it as the transfers one replica serves at once: the transfers a second, times how long one lasts. Two costs set the ceiling. An upload holds up to 16 MiB while it reads and sends a part, so the pool times 16 MiB must fit the container's memory beside everything else; and the replica's bandwidth is shared by every transfer in flight, so more of them each run slower. Raise the replicas, not just the pool, when the saturation rate climbs.
STORAGE_S3_STREAM_MAX_SOCKETS=20 # 20 transfers at once, up to 320 MiB of upload parts
STORAGE_S3_MAX_SOCKETS=50 # the short calls keep the SDK's defaultThe pool keeps slow transfers from starving short calls; it does not keep one client from taking every slot. The ingress in front of the service still needs:
- connections per client, capped below the pool, so one caller cannot hold every stream socket and shed everyone else;
- a minimum transfer rate, so a reader that stops reading, or a writer that stops sending, gives its slot back. Node's HTTP server has none:
HTTP_REQUEST_TIMEOUT_MSbounds what the client sends and nothing bounds how slowly it reads the response, so a stalled download holds its slot until the client hangs up. Set an idle or minimum-rate timeout at the proxy or load balancer in front of the service.
Each pool has its own timeouts, because the two need opposite bounds: a short call should fail fast when the store stalls, so the service sheds and retries it, while a stream is paced by the client at the other end, and a slow reader legitimately leaves the store's socket idle. The short calls (head, get, put, putIfAbsent, copy, delete) take the shared three; the streamed ones take STORAGE_S3_STREAM_*, each falling back to its shared counterpart when unset. Every timeout is per attempt, and the SDK makes three, so a call can take about three times one before it answers.
| Bound | What it cuts |
|---|---|
STORAGE_S3_CONNECTION_TIMEOUT_MS / STORAGE_S3_STREAM_CONNECTION_TIMEOUT_MS | an attempt still without a connected socket: connecting, and on the short pool waiting for a free socket too; the stream pool never queues, so there it bounds connecting only |
STORAGE_S3_REQUEST_TIMEOUT_MS / STORAGE_S3_STREAM_REQUEST_TIMEOUT_MS | an attempt the store has not started answering, its request body included: a whole put or copy; a stream's time to first byte, or one 8 MiB part and the closing request of a streamed write. It never cuts a body being read |
STORAGE_S3_SOCKET_TIMEOUT_MS / STORAGE_S3_STREAM_SOCKET_TIMEOUT_MS | an attempt whose socket sent and received nothing for that long. Under 6 s the SDK arms it at once, so it also cuts a body read that paused; from 6 s it arms it 3 s into the attempt, and only if the store has not answered, so it cuts a silent store, not a paused read |
Size each pool for its own calls:
- The short pool: a socket timeout of a few seconds, under 6 s so a stalled
getbody is cut too, and a request timeout for the slowest short call you accept: the largestputat the store's slowest bandwidth, and acopyof the largest object, which a store may answer only once the copy is done. - The stream pool's socket timeout: 6 s or more, so a reader that pauses is never cut by the store's side; it then bounds a store that stays silent before answering, and a reader that stopped is the ingress's idle timeout to cut. Under 6 s it must outlast the longest pause a legitimate reader makes.
- The stream pool's request timeout: one 8 MiB part at the slowest bandwidth to the store you accept, plus the store's latency; it also bounds a download's time to first byte.
- The stream pool's connection timeout: the shared one usually fits, since connecting costs the same on both pools.
STORAGE_S3_SOCKET_TIMEOUT_MS=5000 # a stalled short call fails within 5 s per attempt
STORAGE_S3_REQUEST_TIMEOUT_MS=30000 # the slowest put or copy accepted
STORAGE_S3_STREAM_SOCKET_TIMEOUT_MS=60000 # the store may stay silent a minute; readers may pause
STORAGE_S3_STREAM_REQUEST_TIMEOUT_MS=90000 # one 8 MiB part at about 1 Mbit/sThe options of the same names are on the reference page.