Skip to content

0.14.0: torrent — make a torrent out of a directory, and get it seeded - #28

Merged
ralyodio merged 2 commits into
masterfrom
add-torrent-command
Aug 29, 2026
Merged

0.14.0: torrent — make a torrent out of a directory, and get it seeded#28
ralyodio merged 2 commits into
masterfrom
add-torrent-command

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Split out of #27, which merged before these commits landed. Depends on nothing in flight.

torrent create ./album              # writes album.torrent, prints the magnet
torrent seed ./album                # …and hands the magnet to torlnk
torrent magnet album.torrent
torrent info album.torrent

create-torrent writes a .torrent and never prints a hash; torlnk takes a magnet rather than a file. The two do not meet without something in between.

The info hash is computed here, not by adding a dependency

A SHA-1 over the bencoded info dictionary. The hard requirement is that the bytes be the original ones — decode and re-encode, and the hash changes the moment a client wrote a key in an order we did not reproduce, which makes it a different torrent. So src/torrent.ts scans bencode for the span and hashes it verbatim, in about forty lines.

The test that matters asserts our hash equals the one a real client computed for a torrent it made itself, so an off-by-one at either end of the span fails loudly rather than producing a plausible-looking wrong magnet.

Trackers are the part that looks cosmetic and is not

A browser can only ever be a WebRTC peer, so a torrent with no wss:// tracker is invisible to every web player — it is on the DHT, desktop clients find it, and the browser sees a torrent with no peers, which reads as a dead torrent rather than as a missing tracker.

Every entry in the default list was checked rather than copied. Of the announce list the WebTorrent tooling ships by default:

tracker state
tracker.leechers-paradise.org no DNS at all
tracker.coppersurfer.tk UDP connect times out
tracker.empire-js.us UDP connect times out
tracker.btorrent.xyz self-signed cert; a browser refuses it

What is left is two WSS trackers that complete a WebSocket handshake and four UDP trackers that return a connection id.

Notes

  • --private is opt-in and named: a private torrent is excluded from the DHT by every client honouring the flag — the opposite of the reason to make one here.
  • --webseed embeds HTTP URLs already serving the same bytes (BEP 19), so the torrent is downloadable before any peer has it. The URL must serve the exact bytes, or the torrent fails its hash check in a way that looks like corruption.
  • Seed duration is deliberately not a flag: torlnk's --seed-time is a daemon setting, not per-torrent, and a flag that silently does nothing is worse than none.

Testing

pnpm test — 491 pass (15 new), pnpm typecheck clean.

Verified against a live torlnk serve daemon, not just the suite: torrent seed created the torrent, computed the magnet, POST /add returned 200, and torlnk picked it up and found peers.

One limitation found while verifying, worth knowing before relying on seed: torlnk does not verify pre-existing local data when a magnet is added. With the files already sitting in its --to directory under the torrent's own name, it still reported status: downloading, progress: 0 rather than seeding what was already on disk. So seed reliably hands the torrent off, but torlnk currently only seeds what it downloaded itself. That is an upstream gap rather than one this PR can close.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LkEHoAhsoDhqVeJ1MJH9yD

ralyodio and others added 2 commits August 29, 2026 16:29
Two programs do the work, the same way `dl` leans on yt-dlp and `vid` on
ffmpeg: `create-torrent` writes the .torrent and torlnk seeds it. What this adds
is the part neither of them does. `create-torrent` writes a file and never
prints a hash; torlnk takes a magnet rather than a file. The two do not actually
meet without something in between.

The info hash is computed here rather than by adding a bencode parser: it is a
SHA-1 over the bencoded `info` dictionary, and the only hard requirement is that
the bytes are the ORIGINAL ones — decode and re-encode and the hash changes the
moment a client wrote a key in an order we did not reproduce. So src/torrent.ts
scans bencode for the span and hashes it verbatim, in about forty lines, with a
test that checks the result against the info hash a real client computed for a
torrent it made itself.

Trackers are the part that looks cosmetic and is not. A browser can only ever be
a WebRTC peer, so a torrent with no wss:// tracker is invisible to every web
player — it is on the DHT, desktop clients find it, and the browser sees a
torrent with no peers, which reads as a dead torrent rather than a missing
tracker. The default list carries both kinds and every entry was checked rather
than copied: of the announce list the WebTorrent tooling ships by default,
tracker.leechers-paradise.org has no DNS at all, coppersurfer.tk and
empire-js.us both time out on a UDP connect, and tracker.btorrent.xyz serves a
self-signed certificate a browser refuses outright. What is left is two WSS
trackers that complete a WebSocket handshake and four UDP trackers that return a
connection id.

`--private` is opt-in and named, because a private torrent is excluded from the
DHT by every client that honours the flag — the opposite of the reason to make
one here.

`seed` hands the magnet to torlnk, which is the process that stays running:
its serve API by default, or a watch directory as the offline handoff. Seed
duration is deliberately not a flag here — torlnk's --seed-time is a daemon
setting rather than a per-torrent one, and inventing a flag that silently does
nothing is worse than not having one.

Verified end to end: create on a real directory produces a torrent whose info
hash matches what create-torrent's own reader reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkEHoAhsoDhqVeJ1MJH9yD
A web seed (BEP 19) is a plain HTTP URL every peer can pull the same bytes from,
which makes the torrent downloadable the moment it exists — before any peer has
it, and with nothing staying running. That is the answer to "I have this at a
URL already" that does not involve seeding it a second time.

The caveat is worth the line it takes: the URL must serve the exact bytes the
torrent was made from, so a redirect to a re-encoded copy produces a torrent
that fails its hash check — which reads as corruption rather than as a
misconfigured seed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkEHoAhsoDhqVeJ1MJH9yD
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

15 finding(s)

HIGH/CRITICAL: 5 | MEDIUM: 1 | LOW: 9

Severity Rule Location
HIGH sh-remote-script-execution root-ubuntu.sh:134
HIGH sh-remote-script-execution root-ubuntu.sh:2597
HIGH sh-remote-script-execution root-ubuntu.sh:2601
HIGH sh-remote-script-execution root-ubuntu.sh:2654
HIGH sh-remote-script-execution root-ubuntu.sh:3683
MEDIUM redos-nested-quantifier src/domain-free.ts:56
LOW secret-generic-credential src/credentials.ts:36
LOW insecure-temp-file test/blog.test.ts:73
LOW insecure-temp-file test/blog.test.ts:74
LOW insecure-temp-file test/credentials.test.ts:43
LOW insecure-temp-file test/credentials.test.ts:44
LOW secret-generic-api-key test/credentials.test.ts:208
LOW insecure-temp-file test/download.test.ts:99
LOW insecure-temp-file test/download.test.ts:100
LOW secret-generic-credential test/shorten.test.ts:36

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 1d375e8 into master Aug 29, 2026
5 checks passed
ralyodio added a commit that referenced this pull request Aug 29, 2026
The free-names branch was cut when master was 0.12.0 and bumped to 0.13.0 in
its feature commit, the convention here. By the time it merged, `torrent` (#28)
and porkbun's `check`/`register` (#29) had taken master to 0.14.0, and the
squash replayed the older bump on top — so a release that added a command
published a lower version than the one before it.

0.15.0 rather than 0.14.1: free-names is a new command, and a new command is a
minor here.

Worth knowing for next time: a hand-maintained version in the feature commit
only works while one branch is in flight. A squash does not conflict on it when
the surrounding lines still match, so nothing catches the regression at merge
time — only reading the tag afterwards does.


Claude-Session: https://claude.ai/code/session_01XGe9mWC6FvUjT6p1HCVjba

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant