WebDAV
Serve a driver over HTTP to a WebDAV client. This transport does not create a mount.
mountx/webdav implements RFC 4918 classes 1, 2 and 3 over any FsDriver. It supports every method in the specification, including write locking. Clients include rclone, curl, cadaver, davfs2, and file-manager connection tools. This server does not create a mount point. Therefore, like S3, it is 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
Each method is a normal HTTP request. You can use curl to call the protocol:
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:Set vendor = other. The owncloud and nextcloud vendors ask for checksums and chunked-upload endpoints this server does not have.
#Mounting it
A WebDAV client can mount the share without a mountx mount transport, and without root or native code. This is the purpose of the transport:
# 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. Direct protocol clients can read and write normally. Tested or compatible clients include rclone, curl, cadaver, browsers, and davfs2. The repository test suite checks the class-2 round trip with real curl. macOS mount_webdav mounts a class-1 share as read-only. The Windows redirector also refuses to write to a class-1 share. Both require the locking implemented here. Therefore, both are expected to write to this share.
Neither platform client has been tested against this server. Development and testing use Linux, so this page does not claim verified compatibility with them.
#Who may connect
The same rule, with the same literal address check, as the S3 gateway:
- No
credentials: the server does not authenticate requests and can bind only to loopback. Before opening a socket, it rejects a non-loopbackhostwithWebdavBindError. This includes0.0.0.0and::, which bind every interface. - With
{ username, password }: every request is authenticated with HTTP Basic (RFC 7617), and anyhostis allowed.
HTTP Basic sends a recoverable password with each 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 |
For any other method, including REPORT, PATCH, and SEARCH, the server returns 405. The Allow header lists the supported methods.
Two responses differ from a basic HTTP server because RFC 4918 requires them:
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
The server derives each property from one stat call or the lock table.
It derives the following properties instead of storing them:
creationdatedisplaynamegetcontentlengthgetcontenttypegetetaggetlastmodifiedresourcetypesupportedlock, which lists the two lock types that this server grantslockdiscovery, which lists locks that cover the resource, including a depth-infinity lock rooted above it
When a driver has statfs(), RFC 4331 quota-available-bytes and quota-used-bytes come from it. The server returns them only when a request names them, as required by RFC 4331 §3.
PROPPATCH can store exactly one property: getlastmodified, through driver.utimes(). The driver must declare the times capability. Without that capability, the property is protected. Returning 200 would falsely report a stored value.
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.
The server does not support dead properties. PROPPATCH refuses them instead of accepting and forgetting them. A driver stores bytes and inode metadata, but it has no storage for arbitrary XML. A sidecar file would incorrectly appear in every listing.
As required by §9.2, the method is atomic. The server processes instructions in document order. They must either all be executed or none executed. If one property cannot be set, all otherwise valid properties return 424 Failed Dependency. The server writes nothing.
getcontentlength and getetag are answered for non-collections only. For a collection, an allprop request omits getcontentlength and getetag. A request that explicitly names either property receives a 404 propstat. The first request asks for available properties; the second asks whether a specific property exists.
#ETags are derived
An ETag contains the first 32 hexadecimal characters of sha256("dev:ino:size:mtimeMs"). It uses the same inputs as the S3 gateway, but without the multipart-shaped -1 suffix.
It is not a hash of the file contents because PROPFIND must not read 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, and clamps .. at the root. Therefore, a request cannot traverse above the driver root.
If a decoded segment contains /, the server returns 400 instead of treating it as a separator. An S3 key can contain a slash, but a POSIX name cannot. Therefore, %2F names no resource on this server.
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 cannot name a symbolic link as a separate object.
It treats the link as its target: GET, PROPFIND and every property follow one.
The two recursive methods do not follow collection links because that behavior can be destructive. DELETE removes the link itself, not the contents of its target. For a link to a collection, COPY reports 403 in its 207 response and does not descend. A link to an ancestor could otherwise make the copy revisit a subtree that it is still writing.
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.
The method guarantees behavior before the first byte. It does not open the destination until one body byte arrives. Therefore, it does not truncate an existing resource or create a new one before that point.
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. A tree COPY is not a transaction. Successful changes remain, and a 207 response names each failed resource. A per-resource status accurately describes the partial result.
#Locking
RFC 4918 class 2 defines write locks in §6 and §7. The server supports 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. For a collection, a depth-zero lock covers the collection and its membership. Creating, removing, or renaming a direct member needs the token. Changing the member's own contents does not.
A depth-infinity lock covers the resource and all descendants, including later additions. A resource created inside a locked collection receives the lock. A resource moved outside it does 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 returns 423 with no-conflicting-lock. The response names the lock root, so the client does not need a PROPFIND to locate it.
#Tokens and leases
The server creates a token as a urn:uuid: Uniform Resource Identifier (URI), as encouraged by §6.5. It returns the token in the Lock-Token response header and the lockdiscovery body.
The token is the complete proof of ownership because the server has at most one principal from credentials. A request proves ownership by holding the token. UNLOCK needs no other identity.
Leases are the server's to choose (§6.6).
A LOCK without a requested timeout receives 10 minutes. Timeout: Infinite receives the 1 hour cap. The server grants intermediate values as requested.
The reply always includes the granted value in its timeout element. Therefore, the client does not have to infer the lease. A LOCK without a body refreshes the lease when its If header names the token, as specified by §9.10.2.
The server uses no timer. When an operation reads the table after a lock deadline, the lock no longer exists. This follows §6.6 and the behavior reported to clients.
Every lock has a finite maximum lease because the server has no administrative interface. Otherwise, an abandoned lock could prevent writes indefinitely.
#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.
The locked empty resource behaves like any other resource. Clients can read, copy, move, and delete it, and it appears in its parent listing. It outlives the lock because clients must therefore be responsible for cleaning up their own mess.
Section 7.3 also permits RFC 2518 lock-null resources, but this server does not implement them. A driver that stores files cannot represent a resource that is neither present nor absent.
#A lock never follows its resource
§7.6 is explicit, and it surprises people: a MOVE does not carry the lock along.
Under §6.1, a request that unmaps a lock root deletes that lock. Moving or deleting the root therefore destroys the lock. The resource reaches its destination unlocked. However, a depth-infinity lock that already covers the destination applies to it.
#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 such as <urn:uuid:...> and entity tags such as ["..."]. Not can negate either type. A list can be tagged with its resource. This lets COPY submit a token for the destination instead of the request URI:
If: </notes/> (<urn:uuid:...>)The header has two separate functions, and RFC 4918 insists they stay separate.
First, it is a precondition. If every list is false, the request returns 412. Second, it is a submission. Every token counts as submitted even when its list was false.
This rule supports the (Not <DAV:no-lock>) form. Adding it makes the header true while still submitting the adjacent tokens.
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 |
A lock never refuses GET, HEAD, PROPFIND, or OPTIONS. Section 7 states that these methods function independently of a write lock. However, an If header on these methods remains a precondition.
Section 7.5 defines the required tokens. COPY needs only the destination token because it does not modify the source. MOVE needs source and destination tokens.
PUT, MKCOL, DELETE, PROPPATCH, and a LOCK that creates an empty resource need the resource token. They also need the parent token when they add or remove a member of a locked collection.
#Conditional requests
The server supports four RFC 9110 conditions on GET, HEAD, and PUT: If-Match, If-None-Match, If-Modified-Since, and If-Unmodified-Since. It evaluates them in the order from §13.2.2 and before Range. A 304 or 412 applies to the complete 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.
For a PUT to an absent URL, §13.1 defines each condition separately. If-Match returns 412 because no representation exists. If-None-Match passes, so If-None-Match: * means create only if absent. The server ignores both date conditions because no modification date exists.
Lock checks have priority over these conditions. A request that fails both checks returns 423. The client must resolve the lock 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 immediately rejects a host that it cannot bind. Waiting until listen() would open a socket before the refusal.
#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)WebdavSession converts one HTTP request to one WebDAV reply without a socket. Therefore, tests need no listener or external client. S3Session, FuseSession, and NfsSession use the same separation.
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 has a streaming boundary in both directions. The request and response bodies can each be an AsyncIterable<Uint8Array>. The server does not buffer a multi-gigabyte PUT or GET.
#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 use DAV: as the default namespace instead of the RFC 4918 D: prefix. For example, they use <multistatus xmlns="DAV:"> with unprefixed children.
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.
As defined by §4, an incoming property name contains a namespace and a local name. Both values identify the property.
Explorer puts its Win32* properties in urn:schemas-microsoft-com:, which this server does not provide. A getlastmodified in that namespace differs from the server's getlastmodified.
The server matches structural request elements only by local name. These elements are <propfind>, <prop>, <set>, and <lockinfo>. They do not identify a property, so the server does not check their namespace. A namespace check would refuse clients that send these bodies without a declaration.
#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 server property is inDAV:. This includes RFC 4918 properties and the RFC 4331 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 cannot name these link types. Therefore, it cannot carry them even when the driver supports them. See Symbolic links for recursive-method behavior.
Content-Typefrom the resource. Not stored, not sniffed: every non-collection answersapplication/octet-stream, and a collectionhttpd/unix-directory.- Windows. The implementation uses
node:httpover a portable driver, but it has not been tested on Windows.