- TypeScript 80%
- Shell 10%
- JavaScript 10%
blobserv #
A small HTTP service that serves atproto blobs from the PDS that holds them,
checked against their CID as they stream. Point a hostname at it — say
blob.example.com — and every blob your apps reference is fetchable at a
stable, forever-cacheable URL, from any origin. It keeps no state, reads no
configuration, and needs nothing but Node 26.
GET /<did-or-handle>/<cid>
curl -I https://blob.example.com/did:plc:rwypz6edxmzyhob2k3rsl2fw/bafkreig4lk2zfr4fez3ustaj5ujgcjlpwr4zzdqqou534u36h7ygd6q26m
curl -I https://blob.example.com/pipobscure.com/bafkreig4lk2zfr4fez3ustaj5ujgcjlpwr4zzdqqou534u36h7ygd6q26m
Once it is published as a bundle, the self-served archive lives here
(latest always names the newest release):
https://blob.pipobscure.com/pipobscure.com/blobserv/latest/blobserv.nzip
URL format #
/<did-or-handle>/<cid>— the blob. The handle resolves to its DID (the_atprotoTXT record, then/.well-known/atproto-did, with the DID required to claim the handle back), the DID to its document (plc.directoryordid:web), and the document names the PDS. The blob comes fromcom.atproto.sync.getBlobthere and is streamed through./<owner>/<repo>/<tag|latest>/<file>— a tangled release artifact. It resolves the repo's record in the owner's PDS, the tag in the appview's index, and the artifact record the owner published, and serves the blob that record carries, checked like any other.latestnames the newest artifact of that file name.
The bytes are checked against the CID. The CID is a sha2-256 of the
bytes, so the URL fixes them exactly: they are hashed as they stream, and
the last chunk is held back until the hash matches. A PDS that serves
anything else gets its response cut off. That is what makes it honest to
say immutable.
Caching: blobs carry Cache-Control: public, max-age=31536000, immutable and ETag: "<cid>"; a matching If-None-Match is a 304
answered without asking the PDS; non-canonical paths (upper-case handle,
upper-case CID) 301 to the canonical one. Errors: 404 (no such account
or blob yet) caches for 5 minutes, 400 (a path that can never be a blob)
for a day, 502 (the PDS or a DID host failed) never.
Safe to embed anywhere: Content-Security-Policy: default-src 'none'; sandbox, nosniff, and types a browser would run rather than show (HTML,
SVG, JavaScript, PDF, …) get Content-Disposition: attachment. Cross-origin
use is open (Access-Control-Allow-Origin: *,
Cross-Origin-Resource-Policy: cross-origin). Range requests are left to
nginx (see below). Blobs over 128 MiB are refused (--max-bytes).
Nothing is fetched from an address that is not public: every connection is
checked after DNS and on every redirect, and only https is used.
Install #
Needs Node ≥ 26.11.1 — nothing else, no configuration. Two ways in:
As a bundle (the published archive, once blobserv is released):
bundle install @pipobscure.com/blobserv
From the repo:
git clone https://tangled.org/pipobscure.com/blobserv && cd blobserv
npm install && npm run release && npm run sign # -> build/blobserv.nzip
sudo bundle install --system ./build/blobserv.nzip
Either way the command is blobserv. Then make it a service:
sudo blobserv install # writes /etc/systemd/system/blobserv.service, enables, starts
sudo blobserv install --port=9000 # other options: --host, --max-bytes
sudo blobserv install --print # just print the unit
sudo blobserv uninstall # stop, disable, remove the unit
journalctl -u blobserv
The unit runs blobserv as a DynamicUser on a read-only system with no
capabilities and nothing writable, listening on 127.0.0.1:8971. The
installer refuses to proceed if that user could not read the node or the
archive it is pointed at — which is the one way this step usually fails:
- Node must be world-readable, not under a home directory or a
group-restricted tree (nvm, fnm, a 0750
/opt/svc/node). Copy it somewhere public and install with that node:cp -a /opt/svc/node /opt/node && chmod 755 /opt/node.bundle install --systemputs both where every user can read them.
nginx #
blobserv listens on loopback; nginx sits in front and adds a disk cache
(20 GB, entries unused for 30 days dropped), which is also what answers
range requests — with proxy_cache on, nginx fetches the whole blob once
and serves every range of it from its cache. Generate the site and put it
in the http context:
blobserv nginx blob.example.com > /etc/nginx/conf.d/blob.example.com.conf
mkdir -p /var/cache/nginx/blobserv && chown www-data /var/cache/nginx/blobserv # nginx's user, before first use
nginx -t && systemctl reload nginx
The generated site has per-name listen 443 and Let's Encrypt certificate
paths. Two things to adapt to your box:
- SNI stream router: if 443 on the host is a TCP TLS-SNI router to a
loopback HTTPS listener with PROXY protocol, change the site to
listen 127.0.0.1:8443 ssl proxy_protocol;(+ the v6 equivalent), withset_real_ip_from 127.0.0.1; real_ip_header proxy_protocol;, and point it at the router's HTTPS port. - Wildcard certificate: if your zone already holds a
*.example.comlineage, reuse its cert paths instead of running certbot per name.
The cache follows blobserv's Cache-Control — blobs stay, errors do not —
and adds X-Cache so you can see hits.
Upgrade / uninstall #
bundle update --system blobserv # replaces the archive in place
sudo systemctl restart blobserv # runs the new one
sudo blobserv uninstall # stop, disable, remove the unit
blobserv install again rewrites the unit (with any new options) and
restarts the service. nginx needs nothing between versions. Removing the
cache directory is all that is left behind.
Developing #
npm install
npm run check # tsc and the tests
npm start # serve on 127.0.0.1:8971 from the source
node --no-warnings src/main.ts serve --port=0 --allow-private # a PDS on localhost
The sources are erasable-syntax TypeScript that node runs as they are, from
disk and from inside the archive; tsc only checks. The one runtime
dependency is multiformats,
for reading CIDs.