`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>
@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/tsconnect—IpnClient,IPN.dial/dialTLS/serveDrive/listDrivePeersused by this package.@webnet/vfs— theAsyncVFScontractTaildriveVFSimplements.@webnet/webdav—DAVClientand the WebDAV serverHandlerthis package bridges to Taildrive.@webnet/http— theContext/HandlershapebridgeDriveHandleradapts.