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 credentials
rclone 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/mountx

Note

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-loopback host with WebdavBindError. This includes 0.0.0.0 and ::, which bind every interface.
  • With { username, password }: every request is authenticated with HTTP Basic (RFC 7617), and any host is 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
OPTIONSDAV: 1, 2, 3, Allow, MS-Author-Via: DAV. Answered without touching the driver, for any target
GET/HEADthe bytes, with ETag, Last-Modified and a single Range (206, or 416 when unsatisfiable)
PUT201 when it created, 204 when it replaced. Content-Range is refused (400)
DELETE204, or a 207 naming what would not go. Depth on a collection must be infinity
MKCOL201. A request body is 415; an existing resource is 405
COPYDepth 0 or infinity; 201/204, or 207 for a tree that only partly copied
MOVEDepth: infinity only, and a rename underneath — so it is atomic when the driver's is
PROPFINDDepth: 0 or 1, 207 multistatus. infinity is 403 propfind-finite-depth
PROPPATCH207 per property: 200 for getlastmodified, 403 cannot-modify-protected-property for the rest
LOCK200 on a resource, 201 on a URL with nothing at it, 423 when a lock is in the way — see locking
UNLOCK204. 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:

  • PUT under a missing parent is 409 Conflict, never 404. Intermediate collections are not created for you (§9.7.1); MKCOL is the client's job. This is the opposite of the S3 gateway, where a prefix is conjured because S3 has no directories to create.
  • GET of a collection is 405. A collection has no body in RFC 4918. The HTML index other servers answer with is a user interface; PROPFIND is 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:

  • creationdate
  • displayname
  • getcontentlength
  • getcontenttype
  • getetag
  • getlastmodified
  • resourcetype
  • supportedlock, which lists the two lock types that this server grants
  • lockdiscovery, 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 /.

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 Failedthe header was there and no state list was true — your copy is stale, re-read the resource
423 Locked + lock-token-submittedthe request would change a locked resource and did not carry its token; the hrefs name the lock roots
207 carrying 423the 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.

optiondefault
host"127.0.0.1"address to bind — see Who may connect
port0an ephemeral port, which WebdavServer.port then reports; never 80 or 8080
credentialsnone{ username, password } — present enables Basic auth and any bind
realm"mountx"the realm named in WWW-Authenticate
drainTimeout5000ms close() lets in-flight responses finish before dropping connections
onTransportErrornone(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 rejects

handleRequest 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

optiondefault
credentialsnone{ username, password }; present authenticates every request
realm"mountx"the realm named in WWW-Authenticate
maxBodyBytesunlimitedcap on a PUT body; over it is 413
maxXmlBytes256 KiBcap on a PROPFIND/PROPPATCH/LOCK document
readChunkBytes128 KiBbytes per positional read while streaming a GET
nowDate.nowthe clock a lock's lease is measured against
lockssee locking{ defaultTimeoutSeconds, maxTimeoutSeconds, maxLocks, newToken }
debugon outside productionrun the reply-exactly-once assertions
onErrornonecalled for every request that ends in an error reply
onAssertioncollectcalled when a dev-mode assertion fails

#The layers below

module
constants.tsthe errno → HTTP status table (total over every ErrnoCode), the protocol's literals, and the propstat phrases
protocol.tspure parsing and document building — target ↔ href, Depth/Overwrite/Destination/Timeout/Lock-Token/If, and every document
locks.tsDavLockTable — the write locks: pure, synchronous and clockless, with now an argument rather than a call
session.tsWebdavSession — the method semantics, over one driver
server.tsthe 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. PROPPATCH stores getlastmodified and refuses every other property rather than accepting one it cannot keep.
  • Conditional requests on DELETE, COPY and MOVE. The four RFC 9110 headers are honoured on GET, HEAD and PUT; on the rest, If is the header to use.
  • Lock-null resources. RFC 2518's; LOCK on an unmapped URL creates RFC 4918's locked empty resource instead.
  • Any property outside DAV:. Every server property is in DAV:. This includes RFC 4918 properties and the RFC 4331 quota pair. A name in another namespace lands in the 404 propstat, 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-Type from the resource. Not stored, not sniffed: every non-collection answers application/octet-stream, and a collection httpd/unix-directory.
  • Windows. The implementation uses node:http over a portable driver, but it has not been tested on Windows.

#Next

  • S3: the other transport that serves rather than mounts.
  • FUSE, 9P and NFS: the three that produce a mount point.

mountx  Write a filesystem in JavaScript, mount it as a real folder.