WebDAV
The other transport that is not a mount: serve a driver over HTTP to anything that speaks WebDAV.
mountx/webdav implements RFC 4918 classes 1, 2 and 3 — every method the specification defines, write locking included — over any FsDriver. rclone, curl, cadaver, davfs2 and a file manager's "connect to server" all talk to it, and nothing here produces a mountpoint, which is why (like S3) it sits outside mountx/auto.
import { createWebdavServer } from "mountx/webdav";
import { createMemoryDriver } from "mountx/drivers/memory";
await using server = await createWebdavServer(createMemoryDriver()).listen();
server.url; // http://127.0.0.1:<port>
// rclone ls :webdav: --webdav-url $server.url --webdav-vendor other#Quick start
#curl
Every method is an ordinary HTTP request, so the protocol is reachable with nothing but curl:
curl -X MKCOL "$URL/notes"
curl -T ./hello.txt "$URL/notes/hello.txt"
curl -X PROPFIND -H 'Depth: 1' "$URL/notes" # a 207 multistatus listing
curl "$URL/notes/hello.txt" # the bytes
curl -X MOVE -H "Destination: $URL/notes/renamed.txt" "$URL/notes/hello.txt"
curl -X DELETE "$URL/notes"#rclone
# rclone.conf, or the equivalent RCLONE_CONFIG_MX_* environment variables
[mx]
type = webdav
url = http://127.0.0.1:PORT
vendor = other
# user / pass only if the server was given credentialsrclone sync ./notes mx:notes
rclone lsjson -R mx:vendor = other is the one that matters: the owncloud and nextcloud vendors ask for checksums and chunked-upload endpoints this server does not have.
#Mounting it
A WebDAV share is mountable without any of this package's mount transports, and without root or native code — which is the reason this transport exists:
# Linux
sudo mount -t davfs http://127.0.0.1:PORT /mnt/point
# macOS
mount_webdav -S http://127.0.0.1:PORT /Volumes/mountxNote
Verified here, and predicted there. Every client that speaks the protocol directly — rclone, curl, cadaver, a browser, davfs2 — reads and writes normally, and the class-2 round trip is exercised against real curl in this repository's test suite. macOS's mount_webdav mounts a class-1 share read-only and the Windows redirector refuses to write to one; both want the locking that is now here, so both are expected to write to this share. Neither has been run against it: the machine this is developed and tested on is Linux, and an untested platform claim is not one this documentation will make.
#Who may connect
The same rule, with the same literal address check, as the S3 gateway:
- No
credentials— every request is served unauthenticated, so the bind is loopback-only: a non-loopbackhost—0.0.0.0and::included, since they bind every interface — is refused outright, before a socket opens, with a namedWebdavBindError. - With
{ username, password }— every request is authenticated with HTTP Basic (RFC 7617), and anyhostis allowed.
Basic sends a recoverable password on every request, which is WebDAV's own default and is why every client implements it. Over anything but a trusted network it wants TLS in front of it; this server speaks plain HTTP and does not pretend otherwise.
#Semantics
#What each method answers
| method | |
|---|---|
OPTIONS | DAV: 1, 2, 3, Allow, MS-Author-Via: DAV. Answered without touching the driver, for any target |
GET/HEAD | the bytes, with ETag, Last-Modified and a single Range (206, or 416 when unsatisfiable) |
PUT | 201 when it created, 204 when it replaced. Content-Range is refused (400) |
DELETE | 204, or a 207 naming what would not go. Depth on a collection must be infinity |
MKCOL | 201. A request body is 415; an existing resource is 405 |
COPY | Depth 0 or infinity; 201/204, or 207 for a tree that only partly copied |
MOVE | Depth: infinity only, and a rename underneath — so it is atomic when the driver's is |
PROPFIND | Depth: 0 or 1, 207 multistatus. infinity is 403 propfind-finite-depth |
PROPPATCH | 207 per property: 200 for getlastmodified, 403 cannot-modify-protected-property for the rest |
LOCK | 200 on a resource, 201 on a URL with nothing at it, 423 when a lock is in the way — see locking |
UNLOCK | 204. 400 with no Lock-Token, 409 lock-token-matches-request-uri when the token names no lock here |
Anything else — REPORT, PATCH, SEARCH — is 405 with an Allow that lists what is really there.
Two of those differ from what a plain HTTP server would answer, and both are RFC 4918 being deliberate:
PUTunder a missing parent is409 Conflict, never404. Intermediate collections are not created for you (§9.7.1);MKCOLis the client's job. This is the opposite of the S3 gateway, where a prefix is conjured because S3 has no directories to create.GETof a collection is405. A collection has no body in RFC 4918. The HTML index other servers answer with is a user interface;PROPFINDis the protocol's own way to list one.
#Properties are all live
Every property is derived from a single stat or from the lock table, and none are stored: creationdate, displayname, getcontentlength, getcontenttype, getetag, getlastmodified, resourcetype, supportedlock (the two lock entries this server grants) and lockdiscovery (the locks that really cover the resource, the depth-infinity one rooted above it included). RFC 4331's quota-available-bytes and quota-used-bytes come from statfs() when the driver has one, and only when a request names them, which is what RFC 4331 §3 requires.
PROPPATCH can store exactly one of them: getlastmodified, through driver.utimes(), and only on a driver that declares the times capability — otherwise the property really is protected here, and answering 200 would be storing nothing. A value that is not an HTTP-date is 409 (§9.2.1: "the client has provided a value whose semantics are not appropriate for the property"). Everything else is 403 cannot-modify-protected-property.
There are no dead properties, and PROPPATCH says so rather than accepting one and forgetting it: a driver stores bytes and inode metadata, and the only place to keep arbitrary XML would be a sidecar file that then shows up in every listing.
The method is atomic, which §9.2 requires: instructions are processed in document order and "either all be executed or none executed", so one property that cannot be set makes every property that could have been 424 Failed Dependency and writes nothing.
getcontentlength and getetag are answered for non-collections only. An allprop request simply leaves them out for a collection; a request that names one gets a 404 propstat for it, which is the difference between "what have you got" and "have you got this".
#ETags are derived
The first 32 hex characters of sha256("dev:ino:size:mtimeMs") — the same inputs as the S3 gateway's, without its multipart-shaped -1 suffix. Never a hash of the bytes: answering a PROPFIND must not mean reading every resource it describes. Two writes inside one millisecond that leave the size unchanged are indistinguishable to it, which is exactly the resolution getlastmodified has.
#Paths, hrefs and the encoded separator
A request target is percent-decoded one segment at a time and then normalized, with .. clamping at the root — so there is no traversal out of the driver by construction. A segment that decodes to something containing a / is refused with 400 rather than read as a separator: an S3 key may contain a slash, a POSIX name may not, so %2F names a resource this server does not have.
Going the other way, every href is percent-encoded per segment, and a collection's ends with /.
#Symbolic links are followed for bytes, never walked
WebDAV has no way to name a link, so a link is the resource it points at: GET, PROPFIND and every property follow one. The two recursive methods deliberately do not, because following a link there is destructive rather than convenient — DELETE removes the link itself (never the contents of what it points at), and COPY reports a link to a collection with 403 in its 207 rather than descending into it, since a link back to any ancestor makes the copy revisit a subtree it is still writing into. A link to a file is followed by COPY and its bytes are copied.
#The write-in-place caveat
PUT writes in place: there is no temporary file and no rename, because the driver interface has no atomic-create primitive to build one on. What is guaranteed is the first byte — the destination is not opened, so an existing resource is not truncated and a new one is not created, until a byte of the body has arrived. A PUT refused at or before then leaves the resource exactly as it was; one that dies mid-body leaves what had been written.
A COPY of a tree is not a transaction either. What succeeded stays, and the reply is a 207 naming each resource that failed — which is why the status is per-resource rather than one code that would describe neither half.
#Locking
Class 2 is RFC 4918's write locks (§6, §7), and all of it is here: exclusive and shared, Depth: 0 and Depth: infinity, leases that lapse, refresh, and the If header that makes a lock mean something.
# Take an exclusive lock on a resource that does not exist yet, reserving the name.
curl -X LOCK -H 'Timeout: Second-300' --data-binary '<?xml version="1.0" encoding="utf-8" ?>
<D:lockinfo xmlns:D="DAV:">
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
<D:owner>ada</D:owner>
</D:lockinfo>' "$URL/notes/draft.txt" # 201 Created, Lock-Token: <urn:uuid:...>
curl -T ./draft.txt "$URL/notes/draft.txt" # 423 Locked
curl -H "If: (<urn:uuid:...>)" -T ./draft.txt "$URL/notes/draft.txt" # 204
curl -X UNLOCK -H "Lock-Token: <urn:uuid:...>" "$URL/notes/draft.txt" # 204#What a lock covers
A depth-0 lock covers the resource it names. On a collection that means the collection and its membership — creating, removing or renaming an internal member needs the token, while the members' own contents do not.
A depth-infinity lock covers the resource and everything under it, now and later: a resource created inside a locked collection is locked by it, and one moved out of it is not. There is no per-member bookkeeping, because the scope is a prefix of a path rather than a list.
Two locks whose scopes overlap conflict unless both are shared, which is §9.10.5's compatibility table. A conflict is 423 with no-conflicting-lock, naming the root of the lock in the way so a client need not go looking for it with a PROPFIND.
#Tokens and leases
A token is a urn:uuid: URI this server mints — the form §6.5 encourages — returned in the Lock-Token response header and in the lockdiscovery body. It is the whole of ownership: there is one principal here at most (credentials), so holding the token is what a request proves, and UNLOCK needs nothing else.
Leases are the server's to choose (§6.6). A LOCK that asks for nothing gets 10 minutes; Timeout: Infinite gets the 1 hour cap; anything in between is honoured as asked. The granted value always goes back in the reply's timeout element, so a client never has to guess what it got, and a LOCK with no body and an If header naming the token restarts the counter (§9.10.2). There is no timer anywhere: a lock stops existing the moment anything looks at the table past its deadline, which is exactly what §6.6 describes and what clients are told to expect.
Nothing is unbreakable, deliberately: this server has no administrative interface, so a lock nobody unlocks would be a resource nobody can write for as long as its lease runs.
#LOCK on a URL with nothing at it
§7.3's locked empty resource: the request creates a real, empty, readable file and answers 201 Created. It behaves as any other resource — it can be read, copied, moved and deleted, and it appears in its parent's listing — and it outlives the lock, because "clients must therefore be responsible for cleaning up their own mess". RFC 2518's lock-null resources are the alternative §7.3 permits, and they are not implemented: a resource that is neither present nor absent has no representation in a driver that stores files.
#A lock never follows its resource
§7.6 is explicit, and it surprises people: a MOVE does not carry the lock along. Instead §6.1 deletes any lock whose root a request unmapped, so moving or deleting a lock root destroys that lock, and the resource arrives at its destination unlocked — except for whatever depth-infinity lock already covers the destination, which picks it up for free.
#Proving you hold one: the If header
If (§10.4) is a disjunction of conjunctions: (a b) (c) is true when a and b both hold, or c does. Conditions are lock tokens (<urn:uuid:...>) and entity tags (["..."]), each optionally negated with Not, and a list can be tagged with the resource it is about — which is how a COPY submits the token for its destination rather than for the request URI:
If: </notes/> (<urn:uuid:...>)The header does two separate jobs, and RFC 4918 insists they stay separate. It is a precondition — if every list is false, the request is 412 — and it is a submission: every token in it counts as submitted whether or not the list carrying it was true. That is what makes the (Not <DAV:no-lock>) idiom work: append it and the header is always true, while the tokens beside it are still submitted.
Which refusal comes back says which of the two failed:
| status | |
|---|---|
412 Precondition Failed | the header was there and no state list was true — your copy is stale, re-read the resource |
423 Locked + lock-token-submitted | the request would change a locked resource and did not carry its token; the hrefs name the lock roots |
207 carrying 423 | the lock is on a member of the tree you named, not on the resource you named; nothing was changed |
GET, HEAD, PROPFIND and OPTIONS are never refused by a lock — §7 is explicit that they "function independently of a write lock" — but an If header on one of them is still a precondition.
Which methods owe which token is §7.5's: a COPY needs the destination's only (the source is not modified), a MOVE needs both ends', and PUT, MKCOL, DELETE, PROPPATCH and the LOCK that creates an empty resource need the resource's own — plus its parent's, when they add or remove an internal member of a locked collection.
#Conditional requests
RFC 9110's four — If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since — are honoured on GET, HEAD and PUT, evaluated in §13.2.2's order and before the Range, since a 304 and a 412 are answers about the whole representation. A 304 carries the validators and no content, not even a Content-Length.
They are evaluated against the derived ETag and the resource's Last-Modified, by the same code the S3 gateway uses. If-Match compares strongly and If-None-Match weakly (§8.8.3.2), so a weak tag from the client fails the first and passes the second.
On a PUT to a URL with nothing at it, §13.1 decides per header: If-Match is 412 (there is no representation to match), If-None-Match passes — which is where If-None-Match: * means "create only if absent" — and the two date forms are ignored, because there is no modification date to compare with.
A lock outranks them: a request that is both locked out and conditionally stale answers 423, which is the one the client has to resolve first.
DELETE, COPY and MOVE ignore all four. The header a WebDAV client reaches for on those is If, which is enforced.
#createWebdavServer(driver, options?)
function createWebdavServer(driver: FsDriver, options?: WebdavServerOptions): WebdavServer;Returns immediately; nothing is bound until listen(). It throws right away for a host it will not bind, because a refusal that waits for listen() is a refusal that has already opened a socket.
#WebdavServerOptions
Extends WebdavSessionOptions, so everything the session takes is settable here too.
| option | default | |
|---|---|---|
host | "127.0.0.1" | address to bind — see Who may connect |
port | 0 | an ephemeral port, which WebdavServer.port then reports; never 80 or 8080 |
credentials | none | { username, password } — present enables Basic auth and any bind |
realm | "mountx" | the realm named in WWW-Authenticate |
drainTimeout | 5000 | ms close() lets in-flight responses finish before dropping connections |
onTransportError | none | (error, peer) — a socket error, or a reply that could not be written |
#WebdavServer
interface WebdavServer extends AsyncDisposable {
readonly session: WebdavSession;
readonly host: string;
readonly port: number;
readonly url: string; // e.g. "http://127.0.0.1:54321"; IPv6 bracketed
readonly connections: number;
listen(): Promise<WebdavServer>; // idempotent; resolves once bound
close(): Promise<void>; // stop accepting, drain, drop — idempotent
}#WebdavBindError / isWebdavBindError() / isLoopbackHost()
class WebdavBindError extends Error {
readonly code: "ERR_WEBDAV_BIND";
readonly host: string;
}The one error type createWebdavServer() throws for an address, named so it can be caught rather than pattern-matched on a message.
#WebdavSession
new WebdavSession(driver: FsDriver, options?: WebdavSessionOptions)One HTTP request in, one WebDAV reply out, with no socket anywhere — the same posture S3Session, FuseSession and NfsSession keep, and what makes the protocol testable with no listener and no client.
session.driver; // Loopback — the driver, normalized, with gaps answering ENOSYS
session.locks; // DavLockTable — every write lock this share holds
session.stats; // { requests, replies, errors, methods: Map<string, number>, assertions }
await session.handleRequest(head, body?); // → WebdavResponse; never rejectshandleRequest's boundary is streaming in both directions: the request body and the reply body may each be an AsyncIterable<Uint8Array>, because a multi-gigabyte PUT or GET is not something to buffer.
#WebdavSessionOptions
| option | default | |
|---|---|---|
credentials | none | { username, password }; present authenticates every request |
realm | "mountx" | the realm named in WWW-Authenticate |
maxBodyBytes | unlimited | cap on a PUT body; over it is 413 |
maxXmlBytes | 256 KiB | cap on a PROPFIND/PROPPATCH/LOCK document |
readChunkBytes | 128 KiB | bytes per positional read while streaming a GET |
now | Date.now | the clock a lock's lease is measured against |
locks | see locking | { defaultTimeoutSeconds, maxTimeoutSeconds, maxLocks, newToken } |
debug | on outside production | run the reply-exactly-once assertions |
onError | none | called for every request that ends in an error reply |
onAssertion | collect | called when a dev-mode assertion fails |
#The layers below
| module | |
|---|---|
constants.ts | the errno → HTTP status table (total over every ErrnoCode), the protocol's literals, and the propstat phrases |
protocol.ts | pure parsing and document building — target ↔ href, Depth/Overwrite/Destination/Timeout/Lock-Token/If, and every document |
locks.ts | DavLockTable — the write locks: pure, synchronous and clockless, with now an argument rather than a call |
session.ts | WebdavSession — the method semantics, over one driver |
server.ts | the socket, and the only file that imports node:http |
Note
Documents go out with DAV: as the default namespace — <multistatus xmlns="DAV:"> with unprefixed children — rather than with RFC 4918's D: prefix. To a namespace-aware parser they are the same document (§14 binds names to the namespace, never to a prefix). A property in another namespace re-declares as it is written, so it goes back out named the way it came in.
Coming in, a property name is the pair §4 defines it to be — a namespace and a local name — and both halves decide which property was named. Explorer's Win32* properties are in urn:schemas-microsoft-com:, so they are properties this server does not have, and a getlastmodified in that namespace is not this server's getlastmodified. The structural elements of the three request grammars (<propfind>, <prop>, <set>, <lockinfo>) are matched on their local name and their namespace is not checked: they identify nothing, and checking them would refuse the clients that send these bodies with no declaration at all.
#Not available
- Dead properties.
PROPPATCHstoresgetlastmodifiedand refuses every other property rather than accepting one it cannot keep. - Conditional requests on
DELETE,COPYandMOVE. The four RFC 9110 headers are honoured onGET,HEADandPUT; on the rest,Ifis the header to use. - Lock-null resources. RFC 2518's;
LOCKon an unmapped URL creates RFC 4918's locked empty resource instead. - Any property outside
DAV:. Every property this server has is inDAV:— RFC 4918's own and RFC 4331's quota pair. A name in another namespace lands in the404propstat, named in the namespace it was asked about. - Symlinks, hardlinks, permissions and access time. WebDAV has no way to name any of them, so there is nothing to carry even where the driver underneath has one — see above for what the recursive methods do when they meet a link anyway.
Content-Typefrom the resource. Not stored, not sniffed: every non-collection answersapplication/octet-stream, and a collectionhttpd/unix-directory.- Windows, not because anything here is platform-specific — it is
node:httpover a portable driver — but because it has not been run there.