Files
webnet/packages/taildrive
codingetandClaude 14bb372490
CI / lint (pull_request) Successful in 2m15s
CI / format (pull_request) Successful in 2m17s
CI / install (pull_request) Successful in 6m54s
CI / typetest (pull_request) Successful in 2m30s
CI / typecheck (pull_request) Successful in 3m3s
CI / node-tests (pull_request) Successful in 3m53s
CI / browser-tests (pull_request) Successful in 7m39s
feat(taildrive): browse every peer's shares as one filesystem
`TaildriveVFS` lists the peers found by `listDrivePeersWithShares` at its root
and delegates everything below one to that peer's `DAVClient`, so a caller sees
`/<peer>/<share>/...` instead of one filesystem per peer.

The root and peer directories are answered locally: an unreachable peer is
simply absent from the listing rather than failing it, and nothing can be
created, modified, or removed at those two levels. Peers are keyed by
`stableNodeID`, and every peer in a display-name collision is listed as
`<name> (<stableNodeID>)` so that the names do not depend on discovery order.
Discovery is cached for `refreshInterval` and can be forced with `refresh()`;
concurrent callers share one probe. One connection pool serves every peer.

Issue #170 specified a `/<magic-dns-suffix>/<peer>/<share>/...` tree. That
first segment is derived from `self.name` and so is constant for every entry
beneath it, which means it disambiguates nothing: `IPNDrivePeer.name` is
already a MagicDNS base name for native peers and an FQDN for shared-in ones,
and the two cannot collide. Dropping it, as the issue discussion asked, also
drops the optional `NetworkMap.Domain` mode and the tsconnect bridge work it
needed.

Closes #170

Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
2026-08-28 23:59:42 +00:00
..

@webnet/taildrive

Taildrive peer discovery, a filesystem over every reachable peer's shares, and a server-side bridge, built on @webnet/webdav.

Taildrive exposes a WebDAV endpoint (/v0/drive) over each Tailscale node's peerapi. On the client side, listDrivePeersWithShares lists an IpnClient's Taildrive peers (via IPN.listDrivePeers) and probes each one over WebDAV, filtering out peers that are unreachable or export no shares; createTaildriveClient returns a @webnet/webdav DAVClient for a given peer, dialing through the IPN's dial/dialTLS methods rather than a real network socket. TaildriveVFS composes those per-peer clients into one AsyncVFS that lists peers at its root and their shares one level down. On the server side, bridgeDriveHandler adapts a @webnet/http Handler into the RawDriveHandler shape IPN.serveDrive expects, translating between the wasm bridge's request/response objects and @webnet/http's Context.

Entry points

Entry point Description
@webnet/taildrive Re-exports both ./client and ./server.
@webnet/taildrive/client TaildriveVFS, createTaildriveClient, listDrivePeersWithShares.
@webnet/taildrive/server bridgeDriveHandler.

Namespace

TaildriveVFS lists peers at its root and delegates everything below one to that peer's client:

/<peer>/<share>/...
/laptop/documents/...
/shared-node.other-tailnet.ts.net/photos/...

The peer segment is IPNDrivePeer.name, which upstream already computes as Node.DisplayName(false): a MagicDNS base name for peers in your own tailnet, and an FQDN without its trailing dot for shared-in peers. Peers are identified internally by stableNodeID, so a display name two peers happen to share is not an identity: every peer in such a collision is listed as <name> (<stableNodeID>) instead.

The root and the peer directories are synthetic. They are listed without contacting anyone, so an unreachable peer cannot break the listing, and they reject attempts to create, modify, or remove them. Copying or moving between two peers rejects with VFSError("unsupported"), which withFallbacks(vfs, { policy: "on-unsupported" }) from @webnet/vfs/fallback turns into a read-and-write transfer.

Usage

import { listDrivePeersWithShares, createTaildriveClient } from "@webnet/taildrive/client"
import type { IpnClient } from "@webnet/tsconnect"

async function browsePeerShares(ipn: IpnClient) {
  const peers = await listDrivePeersWithShares(ipn)
  for (const peer of peers) {
    const client = createTaildriveClient(ipn, peer)
    const { entries } = await client.statAndReaddir("/")
    console.log(
      peer.name,
      entries.map((e) => e.path),
    )
  }
}
import { TaildriveVFS } from "@webnet/taildrive/client"
import type { IpnClient } from "@webnet/tsconnect"

async function browseEveryPeer(ipn: IpnClient) {
  const vfs = new TaildriveVFS(ipn)
  for (const { name } of await vfs.readdir("/")) {
    console.log(
      name,
      (await vfs.readdir(`/${name}`)).map((share) => share.name),
    )
  }
  await vfs.close()
}
import { bridgeDriveHandler } from "@webnet/taildrive/server"
import type { IpnClient } from "@webnet/tsconnect"
import type { Handler } from "@webnet/http/server"

function serveTaildrive(ipn: IpnClient, davHandler: Handler) {
  ipn.serveDrive(bridgeDriveHandler(davHandler))
}

See also

  • @webnet/tsconnectIpnClient, IPN.dial/dialTLS/serveDrive/listDrivePeers used by this package.
  • @webnet/vfs — the AsyncVFS contract TaildriveVFS implements.
  • @webnet/webdavDAVClient and the WebDAV server Handler this package bridges to Taildrive.
  • @webnet/http — the Context/Handler shape bridgeDriveHandler adapts.