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;

const PUBLIC_UPLOAD_CODES = new Set([
  "UNAUTHENTICATED",
  "PERMISSION_DENIED",
  "UPLOAD_MANAGER_INVALID",
  "UPLOAD_FIELD_INVALID",
  "UPLOAD_OPERATION_INVALID",
  "UPLOAD_TARGET_UNAVAILABLE",
  "INVALID_UPLOAD_FILENAME",
  "INVALID_UPLOAD_SIZE",
  "INVALID_UPLOAD_CHECKSUM",
  "UPLOAD_QUOTA_EXCEEDED",
  "UPLOAD_RATE_LIMITED",
  "UPLOAD_EXPIRED",
  "UPLOAD_TOKEN_INVALID",
  "UPLOAD_INCOMPLETE",
  "UPLOAD_ALREADY_CONSUMED",
  "UPLOAD_TRANSFER_CONFLICT",
  "UPLOAD_SUPERSEDED",
  "UPLOAD_BINDING_MISMATCH",
  "UPLOAD_SIZE_MISMATCH",
  "UPLOAD_CHECKSUM_MISMATCH",
  "INVALID_FILE_TYPE",
  "INVALID_IMAGE",
  "UPLOAD_BACKEND_UNSUPPORTED",
  "UPLOAD_STORAGE_CHANGED",
  "UPLOAD_DATABASE_MISMATCH",
  "UPLOAD_FINALIZATION_FAILED",
  "UPLOAD_STORAGE_ERROR",
]);

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) => (
      PUBLIC_UPLOAD_CODES.has(code)
    ),
  });
  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});
}

The isPublicCode allowlist treats the stable upload codes as a client contract, so this recipe can show actionable upload failures without trusting arbitrary server messages. Framework-owned UploadError types use their safe default message and code; custom subclasses are normalized to UPLOAD_STORAGE_ERROR with The file upload could not be completed. Branch on the code, and use the message only for those allowlisted codes.

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.