Serving directories over HTTP with Emacs

Every now and then I find myself needing to serve a directory over HTTP - for instance, to quickly transfer files to another machine in the same LAN (especially when that machine turns out to be a phone).

To accomplish that, I used to run Python's SimpleHTTPServer directly from Fish shell, wrapped in a helper function:

function serve_this; python -m SimpleHTTPServer; end

I'm in process of migrating more and more of my workflow to Emacs - including file management and shell operations - so I naturally started to wonder, how to implement that particular utility in my favorite operating system.

Serving files from Emacs

Emacs is (obviously) perfectly capable of running a HTTP server, and someone (obviously) wrote a package for it - named, appropriately, web-server. Since it's available on ELPA (the default Emacs package repository), it was a good choice to base the implementation on.

The following is the code for basic implementation of the utility, ready to be copied into your config. Further on, I discuss it in more details, and follow up with aesthetic improvements and eshell integration. The complete code with all the improvements can be assembled from pieces posted here, or taken from this gist.

(Note that the code depends on web-server - ensure it's installed and loaded before evaluating this source block. Myself, I just put it in the :config section of use-package invocation.)

(defvar my/file-server nil "Is the file server running? Holds an instance if so.")

(defalias 'my/send-directory-list 'ws-send-directory-list)

(defun my/ws-start (handlers port &optional log-buffer &rest network-args)
  "Like `ws-start', but unbroken for Emacs 25+."
  (let ((server (make-instance 'ws-server :handlers handlers :port port))
        (log (when log-buffer (get-buffer-create log-buffer))))
    (setf (process server)
          (apply
           #'make-network-process
           :name "ws-server"
           :service (port server)
           :filter 'ws-filter
           :server t
           :nowait nil
           :family 'ipv4
           :coding 'no-conversion
           :plist (append (list :server server)
                          (when log (list :log-buffer log)))
           :log (when log
                  (lambda (proc request message)
                    (let ((c (process-contact request))
                          (buf (plist-get (process-plist proc) :log-buffer)))
                      (with-current-buffer buf
                        (goto-char (point-max))
                        (insert (format "%s\t%s\t%s\t%s"
                                        (format-time-string ws-log-time-format)
                                        (first c) (second c) message))))))
           network-args))
    (push server ws-servers)
    server))

(defun my/serve-directory (directory port)
  (interactive "DDirectory: \nnPort: ")
  ;; Based on http://eschulte.github.io/emacs-web-server/File-Server.html#File-Server.
  (if my/file-server
      (message "File server is already running!")
    (progn
      (setf my/file-server
            (lexical-let ((docroot directory))
              (my/ws-start
               (lambda (request)
                 (with-slots (process headers) request
                   (let* ((path (substring (cdr (assoc :GET headers)) 1))
                          (expanded (ws-in-directory-p docroot path)))
                     (if (and expanded
                              (file-exists-p expanded))
                         (if (file-directory-p expanded)
                             (my/send-directory-list process expanded)
                           (ws-send-file process (expand-file-name path docroot)))
                       (ws-send-404 process)))))
               port
               nil                      ;no log buffer
               :host "0.0.0.0")))
      (message "Serving directory %s on port %d" directory port))))

(defun my/stop-server ()
  "Stop the file server if running."
  (interactive)
  (if my/file-server
      (progn
        (ws-stop my/file-server)
        (setf my/file-server nil)
        (message "Stopped the file server."))
    (message "No file server is running.")))

Evaluating the above code is enough to get the basic functionality, as presented below:

basic-server.png

Serving directories is as simple as running M-x my/serve-directory, and specifying directory and server port. Stopping the server can be accomplished with M-x my/stop-server.

my/ws-start workaround

The web-server package doesn't work correctly by default under Emacs 26 (and possibly 25). This is due to apparent bug in handling of calls to make-network-process (see here, here). The simplest way to patch it was to copy the original ws-start function and modify the make-network-process call to use nil as the value of :nowait option.

If you're using Emacs 25 and :nowait nil works for you, consider removing the my/ws-start function and using the ws-start function instead.

(Note that if only ws-start prepended its network-args list in the apply call instead of putting it at the end, I would be able to override the :nowait value by just passing it as an argument. Alas, I had to fix the entire function.)

Sending the directory listing

The HTTP handler passed as an argument to my/ws-start uses the path passed in the request to determine whether the target resource is a file or directory. To send a directory, the code above uses another function from the web-server package - ws-send-directory-list (through the alias my/send-directory-list). Let's look at that function's signature:

(defun ws-send-directory-list (proc directory &optional match)
  "Send a listing of files in DIRECTORY to PROC.
Optional argument MATCH is passed to `directory-files' and may be
used to limit the files sent."
  ...)

The docstring tells us about the match argument being passed to directory-files Emacs function. From documentation:

If MATCH is non-nil, mention only file names that match the regexp MATCH.

This argument can be used to filter out files and directories from the returned listing. For instance, the example file server implementation I'm building on uses "^[^\.]" to filter out the ".", ".." pseudo-directories and (possibly unintentionally) dotfiles. In my implementation I left it out, however, to allow the improved directory listing code (below) to filter out "." and ".." manually.

Prettifying the file list

Let's compare that with the default output of Python's SimpleHTTPServer:

python-server.png

Pythons is definitely prettier. Fortunately, it's simple to improve on the code above, and also add some extra useful information - like sizes and last modification dates for each entry. This requires writing a custom implementation for outputting directory listing.

Replace (defalias 'my/send-directory-list 'ws-send-directory-list) with the following function:

(defun my/send-directory-list (proc directory &optional match)
  "Send a listing of files in DIRECTORY to PROC.
Optional argument MATCH is passed to `directory-files' and may be
used to limit the files sent."
  (ws-response-header proc 200 (cons "Content-type" "text/html"))
  (process-send-string proc
                       (concat
                        ;; header
                        "<!DOCTYPE html>\n"
                        "<html><head><title>Directory listing for "
                        directory
                        "</title></head><body>"
                        "<h2>Directory listing for <tt>"
                        directory
                        "</tt></h2>"
                        "<table>"
                        ;; "Up one level" link
                        "<tr><td></td><td></td><td><a href='../'>Up one level</a></td></tr>"
                        ;; Actual directory listing
                        (mapconcat (lambda (f-and-attr)
                                     (let* ((name (first f-and-attr))
                                            (attr (rest f-and-attr))
                                            (full (expand-file-name name directory))
                                            (end (if (file-directory-p full) "/" ""))
                                            (url (url-encode-url (concat name end)))
                                            (modtime (format-time-string "%Y-%m-%d %T %z" (file-attribute-modification-time attr)))
                                            (size (file-attribute-size attr)))
                                       (format "<tr><td><small>%s</small></td><td><small>%s</small></td><td><a href='%s'>%s%s</a></td></tr>" modtime size url name end)))
                                   (remove-if (lambda (entry)
                                                (or (string= (car entry) ".")
                                                    (string= (car entry) "..")))
                                              (directory-files-and-attributes directory nil match))
                                   "\n")
                        ;; Footer
                        "</table></body></html>")))

better-server.png

Much better!

Previously, I mentioned that web-server's implementation uses directory-files function to list files. The improved code uses its sibling - directory-files-and-attributes - which returns entries in the form of (name . attributes). Compare:

(directory-files "/tmp/test-directory")
;; ->
("." ".." ".emacs" ".gitconfig" "a-subdirectory")

(directory-files-and-attributes "/tmp/test-directory")
;; ->
(("." t 3 1000 1000 (23305 35088 307349 257000) (23305 35068 419401 610000) (23305 35068 419401 610000) 4096 "drwxrwxr-x" t 5636571 2065)
 (".." t 25 0 0 (23305 34969 731662 280000) (23305 35020 155528 902000) (23305 35020 155528 902000) 12288 "drwxrwxrwt" t 5636097 2065)
 (".emacs" "/home/temporal/repos/conffiles/emacs25/.emacs" 1 1000 1000 (23305 35053 639440 555000) (23305 35053 635440 565000) (23305 35053 635440 565000) 45 "lrwxrwxrwx" t 5636573 2065)
 (".gitconfig" nil 1 1000 1000 (23304 15357 454324 784000) (23151 13649 999692 371000) (23305 35068 419401 610000) 277 "-rw-r--r--" t 5636575 2065)
 ("a-subdirectory" t 2 1000 1000 (23305 35025 19516 58000) (23305 35025 19516 58000) (23305 35025 19516 58000) 4096 "drwxrwxr-x" t 5636572 2065))

directory-files-and-attributes returns plenty of useful attributes - including access/modification/status change times, permission strings in format like ls -l, and an indication whether an entry is a file, directory, or a symbolic link. Emacs also defines helper functions for convenient access to returned attributes - see M-x apropos file-attribute-* for a list.

Convenience and integrating with eshell

Ultimately, I wanted to replicate the convenience of typing serve_this in the shell. Nowadays, I do most of my shell work in eshell, which has a very convenient feature - any elisp function with a name starting with eshell/ (e.g. eshell/foo) is available in eshell as a command (e.g. foo). The code below uses this to implement serving current directory and stopping the server as eshell commands - serve-this and stop-server, respectively. serve-this can accept an optional number, if you want to pick the port on which to serve files.

The code also implements a M-x serve-this interactive command for easy access from anywhere within Emacs.

(defun my/serve-this (port)
  "Start a file server on a `PORT', serving the content of directory
associated with the current buffer's file."
  (interactive "nPort: ")
  (my/serve-directory (if (buffer-file-name)
                          (file-name-directory (buffer-file-name))
                        (expand-file-name default-directory))
                      port))

;; Eshell utilities.
(defconst my/default-directory-server-port 8123)

(defun eshell/serve-this (&optional port)
  (my/serve-this (or port my/default-directory-server-port)))

(defalias 'eshell/stop-server 'my/stop-server)

That's it! A complete and relatively nice-looking solution for serving directories and files over HTTP, written entirely within Emacs!

Further considerations

The above solution is pretty much complete, and it's enough for my typical usage patterns. I have, however, some potential ideas for improvements.

Logging

The functions ws-start and my/ws-start both accept an optional log-buffer argument. It can be used to make the server process use (or create) a new buffer, in which it'll log requests. I don't need it, but it's trivial to modify my/serve-directory to make use of it. The argument can be a buffer object, or a string - if that string doesn't name an existing buffer, a new buffer will be created. This buffer probably doesn't need undo functionality, so you may want to consider passing a string starting with space - per documentation of get-buffer-create, a new buffer with such name will not keep undo information.

Multiple servers

Yet another feature that I personally don't need, but if you wanted, you could make my/serve-directory and my/stop-server work on a list or hash table of server instances, keyed by port number, instead of sharing the my/file-server global variable.