How-to guide

Upload a file over the size limit

You will pin a file larger than an atfs instance's own request-body limit, by handing it over from a short-lived IPFS node instead of posting it directly.


Every atfs instance sits behind an HTTP ingress with its own request-body cap. A direct upload also refuses anything over the instance’s maxBlobSize. Neither cap stops a file from reaching the instance, though. Build it into IPFS blocks yourself, and hand the instance a dev.atfs.file reference. Ask it to fetch the bytes over the IPFS network, instead of over HTTP.

Before you start

  • Your atproto account must already be in the instance’s upload allowlist. Pinning needs the same allowlist membership as a direct upload.
  • This walkthrough targets Node.js. It dials the instance’s own libp2p address directly, over TCP or QUIC. A browser-only version, over a WebSocket, is planned. It has not shipped yet — see the warning on Expose to the internet.
  • Packages: helia, @helia/unixfs, multiformats, @multiformats/multiaddr.

1. Start a short-lived IPFS node

import { createHelia } from "helia";
import { unixfs } from "@helia/unixfs";

const helia = await createHelia();
const fs = unixfs(helia);

This node only needs to live long enough to hand your file to the instance. Nothing here needs configuring for a first attempt.

2. Import the file, matching atfs’s own chunker

import { readFile } from "node:fs/promises";
import { CID } from "multiformats/cid";
import { sha256 } from "multiformats/hashes/sha2";
import * as raw from "multiformats/codecs/raw";

const bytes = await readFile("./release.img");

const ipfsRoot = await fs.addBytes(bytes, {
  cidVersion: 1,
  rawLeaves: true,
});

const cid = CID.createV1(raw.code, await sha256.digest(bytes));

A dev.atfs.file reference needs two different CIDs. ipfsRoot is the UnixFS DAG root that addBytes built: the entry point for fetching the bytes. The blessed CID of the whole file is separate, in the cid field — a hash of the raw bytes, not of the DAG built over them.

Note

Only cidVersion and rawLeaves are set explicitly above. Today’s @helia/unixfs already defaults to atfs’s own 256 KiB chunk size and balanced, 174-link layout. See IPFS chunking defaults for the full recipe. Pinning every value explicitly is worth doing anyway, rather than trusting a library’s current defaults to stay put.

3. Connect to the instance

pinFile only starts fetching once your node is reachable. Look up the instance’s libp2p address with dev.atfs.describeServer, and dial it before doing anything else:

import { multiaddr } from "@multiformats/multiaddr";

const { serviceDid, peerId, multiaddrs } = await fetch(
  "https://your-instance.example/xrpc/dev.atfs.describeServer",
).then((res) => res.json());

for (const addr of multiaddrs) {
  await helia.libp2p.dial(multiaddr(`${addr}/p2p/${peerId}`));
}

Dial before calling pinFile, not after. The instance broadcasts its want-list to every peer it is already connected to. A peer that connects later is only picked up on the next broadcast.

Warning

An empty multiaddrs array is a real answer, not a bug: the instance currently has nothing dialable over libp2p. Its own network reachability is outside your control — wait and retry, or check with whoever runs it.

4. Ask the instance to pin it

const auth = await session.fetchHandler(
  `/xrpc/com.atproto.server.getServiceAuth?aud=${serviceDid}&lxm=dev.atfs.repo.pinFile`,
  { method: "GET" },
);
const { token } = await auth.json();

const res = await fetch(
  "https://your-instance.example/xrpc/dev.atfs.repo.pinFile",
  {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({
      file: {
        cid: { $link: cid.toString() },
        ipfsRoot: { $link: ipfsRoot.toString() },
        size: bytes.length,
        mimeType: "application/octet-stream",
      },
    }),
  },
);

console.log(await res.json()); // { state: "seeking" | "fetching" | ... }

pinFile needs the same inter-service auth as a direct upload. Your own account mints a short-lived token naming this instance’s serviceDid and the dev.atfs.repo.pinFile method — see com.atproto.server.getServiceAuth for the shape. That minting call is whatever your own atproto client library already provides; the example above stands in for it as session.fetchHandler.

5. Wait for it to land

pinFile returns at once. The fetch itself runs in the background. Call it again with the same reference to check on it — each response’s state moves from seeking (no source found yet), through fetching (bytes arriving), to pinned. Keep your node running, and connected, until you see pinned:

let state;
do {
  await new Promise((r) => setTimeout(r, 5000));
  state = (await callPinFileAgain()).state; // same request as step 4
} while (state !== "pinned" && state !== "failed");

await helia.stop();

Once pinned, the instance holds the file itself. Your short-lived node can go offline for good.

A different chunker still works

None of this needs atfs’s chunker matched exactly. A reference built by any UnixFS-compliant importer still gets fetched and verified the same way. The instance adopts whatever DAG shape it is handed, the first time it sees that content.

Matching the defaults just makes the fetch cheaper for the instance. A raw-leaf DAG serves its leaves straight from bytes it already has. Any other shape costs it a second copy of the file, to serve those leaves back out verbatim.