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.