Skip to content

Upload a file through GraphQLΒΆ

This browser-side recipe assumes the server is configured with the local upload guide. It calculates the exact SHA-256 expected by beginFileUpload, performs the returned transfer, and then consumes the one-time token in a generated update mutation.

import {
  GraphQLRequestError,
  mapGraphQLErrors,
} from "./graphql-errors.js";

// Web Crypto does not expose incremental SHA-256. Keep this limit no higher
// than the server's upload policy, or use a reviewed streaming hash library.
const MAX_CLIENT_HASH_BYTES = 25_000_000;

async function sha256Hex(file) {
  if (file.size > MAX_CLIENT_HASH_BYTES) {
    throw new RangeError("The file is too large to hash in this browser flow.");
  }
  const bytes = await file.arrayBuffer();
  const digest = await crypto.subtle.digest("SHA-256", bytes);
  return [...new Uint8Array(digest)]
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

async function graphql(query, variables) {
  const response = await fetch("/graphql", {
    method: "POST",
    credentials: "same-origin",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({query, variables}),
  });
  const payload = await response.json();
  const failures = mapGraphQLErrors(payload.errors ?? [], {
    isPublicCode: (code) => (
      code === "UNAUTHENTICATED"
      || code === "INVALID_FILE_TYPE"
      || code === "INVALID_IMAGE"
      || code.startsWith("UPLOAD_")
      || code.startsWith("INVALID_UPLOAD_")
    ),
  });
  if (failures.length) throw new GraphQLRequestError(failures);
  return payload.data;
}

async function uploadAvatar(profileId, file) {
  const begin = await graphql(`
    mutation Begin($id: ID!, $name: String!, $size: BigIntScalar!,
                   $type: String!, $digest: String!) {
      beginFileUpload(
        manager: "Profile", field: "avatar", operation: UPDATE,
        objectId: $id, filename: $name, size: $size,
        contentType: $type,
        checksum: {algorithm: SHA256, digest: $digest}
      ) {
        token transport uploadUrl method
        headers { name value }
      }
    }
  `, {
    id: String(profileId),
    name: file.name,
    size: String(file.size),
    type: file.type || "application/octet-stream",
    digest: await sha256Hex(file),
  });

  const instructions = begin.beginFileUpload;
  const headers = Object.fromEntries(
    instructions.headers
      .filter(({name}) => name.toLowerCase() !== "content-length")
      .map(({name, value}) => [name, value]),
  );
  const transfer = await fetch(instructions.uploadUrl, {
    method: instructions.method,
    credentials: instructions.transport === "PROXY" ? "same-origin" : "omit",
    headers,
    body: file,
  });
  if (!transfer.ok) {
    const payload = await transfer.json().catch(() => null);
    const code = typeof payload?.error?.code === "string"
      ? payload.error.code
      : "UPLOAD_FAILED";
    throw new GraphQLRequestError([
      {code, message: "The upload transfer failed."},
    ]);
  }

  return graphql(`
    mutation Finish($id: Int!, $token: UploadToken!) {
      updateProfile(id: $id, avatar: $token) {
        Profile { avatar { name status downloadUrl expiresAt } }
      }
    }
  `, {id: profileId, token: instructions.token});
}

Do not retry the final mutation with the same token once the intent reaches FINALIZING. Poll the normal profile query while the returned status is PROCESSING; begin a new upload after a terminal token error. This workflow and its stable Python extension contracts were introduced in 0.62.0.

The 25 MB browser hashing limit matches GeneralManager's default MAX_BYTES. Lower it when the field policy is smaller. For larger configured uploads, use a reviewed incremental SHA-256 implementation instead of increasing the buffered limit without considering client memory.

See the concept page, the S3 task guide, and the API reference.