@mrzr/api-client

Uploads & Downloads

Upload FormData, File, Blob and binary bodies with @mrzr/api-client, keep long uploads from failing on token expiry, and download files as a Blob.

Uploading

Pass the body as-is. The client doesn't JSON-encode it and picks the right Content-Type:

const form = new FormData();
form.append("avatar", file);
await api.post("/me/avatar", form);   // multipart, boundary set by the browser

await api.put("/files/1", file);      // File or Blob: sent as raw bytes
await api.post("/raw", new Uint8Array([1, 2, 3]));
await api.post("/login-form", new URLSearchParams({ user, pass }));
BodySent as
Plain object or arrayJSON
FormDatamultipart/form-data
File, BlobRaw bytes with the file's type
ArrayBuffer, typed arrays, DataViewRaw bytes
URLSearchParamsapplication/x-www-form-urlencoded
ReadableStreamStreamed (see below)
stringAs-is, with Content-Type: application/json unless you set another

Which Content-Type wins

  1. A header you set on the request.
  2. A client-wide header that isn't application/json. It's treated as deliberate.
  3. Otherwise the body's own type: the client-wide JSON default is dropped for files, bytes and forms.

FormData always gets the browser's multipart boundary. A hand-written multipart/form-data header without boundary= is removed, because it would corrupt the upload.

await api.post("/upload", pngBytes, { headers: { "Content-Type": "image/png" } });

Long uploads

The default timeout is 30 s. Raise it, or turn it off, for big files:

await api.post("/upload", form, { timeout: 10 * 60_000 });
await api.post("/upload", form, { timeout: 0 });   // no limit

If the token expires mid-upload, the server answers 401 and the client refreshes and re-sends the file automatically. To avoid uploading twice, refresh before starting when the token won't last the whole upload:

await api.post("/upload", form, { uploadSkewMs: 10 * 60_000 });

For very large files, upload in chunks. Each chunk is a normal request with its own timeout and refresh handling, and you get progress and resuming for free:

const CHUNK = 5 * 1024 * 1024;
for (let i = 0; i * CHUNK < file.size; i++) {
  await api.post("/upload/chunk", file.slice(i * CHUNK, (i + 1) * CHUNK), {
    params: { uploadId, index: i },
    headers: { "Content-Type": "application/octet-stream" },
  });
  setProgress(Math.min(1, ((i + 1) * CHUNK) / file.size));
}
await api.post("/upload/complete", { uploadId });

Streams

A ReadableStream body works only with worker: false, because a stream can't be passed into a Web Worker. In worker mode you get a clear error instead: A ReadableStream body cannot be sent through a Web Worker. Send a Blob or File, or create a separate client for streaming:

const streamApi = createClient({ baseUrl, worker: false });
await streamApi.post("/upload", stream, { uploadSkewMs: 10 * 60_000, timeout: 0 });

A stream also can't be replayed after a 401. The request fails with a 401 that says so, but the token has been refreshed, so sending a fresh stream succeeds. uploadSkewMs avoids the problem in the first place.

Downloading files

Non-text responses (images, PDFs, zip files, Office files such as xlsx and docx) resolve as a Blob:

const { data } = await api.get<Blob>("/reports/42.pdf");
const url = URL.createObjectURL(data!);

Pass responseType: "blob" or "arrayBuffer" to force a format. A "save as" download goes through the client, so it's authenticated and refreshes like any request:

export async function download(path: string, filename: string) {
  const { data } = await api.get<Blob>(path, { responseType: "blob", timeout: 0 });
  const url = URL.createObjectURL(data!);
  Object.assign(document.createElement("a"), { href: url, download: filename }).click();
  URL.revokeObjectURL(url);
}

When a download fails with a JSON or text body, that body is still parsed, so message, errors and ApiError.data hold the server's error:

try {
  const { data } = await api.post<Blob>("/reports/export", filters, { responseType: "blob" });
  save(data!);
} catch (error) {
  if (error instanceof ApiError) toast(error.message); // the server's message, not a Blob
}

Progress

fetch has no upload progress event, so the client can't report one. Chunked uploads (above) give you progress per chunk. For a byte-level progress bar, use XMLHttpRequest for that one upload and the client for everything else.

On this page