diff --git a/samples/static-files-from-disk/config.capnp b/samples/static-files-from-disk/config.capnp
new file mode 100644
index 00000000000..9cb267bf4f1
--- /dev/null
+++ b/samples/static-files-from-disk/config.capnp
@@ -0,0 +1,51 @@
+# This is a simple demo showing how to serve a static web site from disk.
+#
+# By default this will serve a subdirectory called `content-dir` from whatever directory `workerd`
+# runs in, but you can override the directory on the command-line like so:
+#
+# workerd serve config.capnp --directory-path site-files=/path/to/files
+
+using Workerd = import "/workerd/workerd.capnp";
+
+# A constant of type `Workerd.Config` will be recognized as the top-level configuration.
+const config :Workerd.Config = (
+ services = [
+ # The site worker contains JavaScript logic to serve static files from a directory. The logic
+ # includes things like setting the right content-type (based on file name), defaulting to
+ # `index.html`, and so on.
+ (name = "site-worker", worker = .siteWorker),
+
+ # Underneath the site worker we have a second service which provides direct access to files on
+ # disk. We only configure site-worker to be able to access this (via a binding, below), so it
+ # won't be served publicly as-is. (Note that disk access is read-only by default, but there is
+ # a `writable` option which enables PUT requests.)
+ (name = "site-files", disk = "content-dir"),
+ ],
+
+ # We export it via HTTP on port 8080.
+ sockets = [ ( name = "http", address = "*:8080", http = (), service = "site-worker" ) ],
+);
+
+# For legibility we define the Worker's config as a separate constant.
+const siteWorker :Workerd.Worker = (
+ # All Workers must declare a compatibility date, which ensures that if `workerd` is updated to
+ # a newer version with breaking changes, it will emulate the API as it existed on this date, so
+ # the Worker won't break.
+ compatibilityDate = "2022-09-16",
+
+ # This worker is modules-based.
+ modules = [
+ (name = "static.js", esModule = embed "static.js"),
+ ],
+
+ bindings = [
+ # Give this worker permission to request files on disk, via the "site-files" service we
+ # defined earlier.
+ (name = "files", service = "site-files"),
+
+ # This worker supports some configuration options via a JSON binding. Here we set the option
+ # so that we hide the `.html` extension from URLs. (See the code for all config options.)
+ (name = "config", json = "{\"hideHtmlExtension\": true}")
+ ],
+);
+
diff --git a/samples/static-files-from-disk/content-dir/index.html b/samples/static-files-from-disk/content-dir/index.html
new file mode 100644
index 00000000000..89d9f044f07
--- /dev/null
+++ b/samples/static-files-from-disk/content-dir/index.html
@@ -0,0 +1,7 @@
+
+
+
+
+ Hello world!
+
+
diff --git a/samples/static-files-from-disk/static.js b/samples/static-files-from-disk/static.js
new file mode 100644
index 00000000000..8008bf9c441
--- /dev/null
+++ b/samples/static-files-from-disk/static.js
@@ -0,0 +1,190 @@
+// An example Worker that serves static files from disk. This includes logic to do things like
+// set Content-Type based on file extension, look for `index.html` in directories, etc.
+//
+// This code supports several configuration options to control the serving logic, but, better
+// yet, because it's just JavaScript, you can freely edit it to suit your unique needs.
+
+export default {
+ async fetch(req, env) {
+ if (req.method != "GET" && req.method != "HEAD") {
+ return new Response("Not Implemented", {status: 501});
+ }
+
+ let url = new URL(req.url);
+ let path = url.pathname;
+ let origPath = path;
+
+ let config = env.config || {};
+
+ if (path.endsWith("/") && !config.allowDirectoryListing) {
+ path = path + "index.html";
+ }
+
+ let content = await env.files.fetch("http://dummy" + path, {method: req.method});
+
+ if (content.status == 404) {
+ if (config.hideHtmlExtension && !path.endsWith(".html")) {
+ // Try with the `.html` extension.
+ path = path + ".html";
+ content = await env.files.fetch("http://dummy" + path, {method: req.method});
+ }
+
+ if (!content.ok && config.singlePageApp) {
+ // For any file not found, serve the main page -- NOT as a 404.
+ path = "/index.html";
+ content = await env.files.fetch("http://dummy" + path, {method: req.method});
+ }
+
+ if (!content.ok) {
+ // None of the fallbacks worked.
+ //
+ // Look for a 404 page.
+ content = await env.files.fetch("http://dummy/404.html", {method: req.method});
+
+ if (content.ok) {
+ // Return it with a 404 status code.
+ return wrapContent(req, 404, "404.html", content.body, content.headers);
+ } else {
+ // Give up and return generic 404 message.
+ return new Response("404 Not found", {status: 404});
+ }
+ }
+ }
+
+ if (!content.ok) {
+ // Some error other than 404?
+ console.error("Fetching '" + path + "' returned unexpected status: " + content.status);
+ return new Response("Internal Server Error", {status: 500});
+ }
+
+ if (content.headers.get("Content-Type") == "application/json") {
+ // This is a directory.
+ if (path.endsWith("/")) {
+ // This must be because `allowDirectoryListing` is `true`, so this is actually OK!
+ let listingHtml = null;
+ if (req.method != "HEAD") {
+ let html = await makeListingHtml(origPath, await content.json(), env.files);
+ return wrapContent(req, 200, "listing.html", html);
+ }
+ } else {
+ // redirect to add '/' suffix.
+ url.pathname = path + "/";
+ return Response.redirect(url);
+ }
+ }
+
+ if (origPath.endsWith("/index.html")) {
+ // The file exists, but was requested as "index.html", which we want to hide, so redirect
+ // to remove it.
+ url.pathname = origPath.slice(0, -"index.html".length);
+ return Response.redirect(url);
+ }
+
+ if (config.hideHtmlExtension && origPath.endsWith(".html")) {
+ // The file exists, but was requested with the `.html` extension, which we want to hide, so
+ // redirect to remove it.
+ url.pathname = origPath.slice(0, -".html".length);
+ return Response.redirect(url);
+ }
+
+ return wrapContent(req, 200, path.split("/").pop(), content.body, content.headers);
+ }
+}
+
+function wrapContent(req, status, filename, contentBody, contentHeaders) {
+ let type = TYPES[filename.split(".").pop().toLowerCase()] || "application/octet-stream";
+ let headers = { "Content-Type": type };
+ if (type.endsWith(";charset=utf-8")) {
+ let accept = req.headers.get("Accept-Encoding") || "";
+ if (accept.split(",").map(s => s.trim()).includes("gzip")) {
+ // Apply gzip encoding on the fly.
+ // TODO(someday): Support storing gziped copies of files on disk in advance so that gzip
+ // doesn't need to be applied on the fly.
+ headers["Content-Encoding"] = "gzip";
+ }
+ }
+
+ if (req.method == "HEAD" && contentHeaders) {
+ // Carry over Content-Length header on HEAD requests.
+ let len = contentHeaders.get("Content-Length");
+ if (len) {
+ headers["Content-Length"] = len;
+ }
+ }
+
+ return new Response(contentBody, {headers, status});
+}
+
+let TYPES = {
+ txt: "text/plain;charset=utf-8",
+ html: "text/html;charset=utf-8",
+ htm: "text/html;charset=utf-8",
+ css: "text/css;charset=utf-8",
+ js: "text/javascript;charset=utf-8",
+ md: "text/markdown;charset=utf-8",
+ sh: "application/x-shellscript;charset=utf-8",
+ svg: "image/svg+xml;charset=utf-8",
+ xml: "text/xml;charset=utf-8",
+
+ png: "image/png",
+ jpeg: "image/jpeg",
+ jpg: "image/jpeg",
+ jpe: "image/jpeg",
+ gif: "image/gif",
+
+ ttf: "font/ttf",
+ woff: "font/woff",
+ woff2: "font/woff2",
+ eot: "application/vnd.ms-fontobject",
+
+ // When serving files with the .gz extension, we do NOT want to use `Content-Encoding: gzip`,
+ // because this will cause the user agent to unzip it, which is usually not what the user wants
+ // when downloading a gzipped archive.
+ gz: "application/gzip",
+ bz: "application/x-bzip",
+ bz2: "application/x-bzip2",
+ xz: "application/x-xz",
+ zst: "application/zst",
+}
+
+async function makeListingHtml(path, listing, dir) {
+ if (!path.endsWith("/")) path += "/";
+
+ let htmlList = [];
+ for (let file of listing) {
+ let len, modified;
+ if (file.type == "file" || file.type == "directory") {
+ let meta = await dir.fetch("http://dummy" + path + file.name, {method: "HEAD"});
+ console.log(meta.status, "http://dummy" + path + file.name, meta.headers.get("Content-Length"));
+ len = meta.headers.get("Content-Length");
+ modified = meta.headers.get("Last-Modified");
+ }
+
+ len = len || `(${file.type})`;
+ modified = modified || "";
+
+ htmlList.push(
+ ` ` +
+ `${file.name} | ` +
+ `${modified} | ${len} |
`);
+ }
+
+ return `
+
+
+ Index of ${path}
+
+
+
+ Index of ${path}
+
+ Filename | Modified | Size |
+ ${htmlList.join("\n")}
+
+
+`
+}