From 46119264c20bc3da7db2ce5cefa983e5d564c7b6 Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Tue, 27 Sep 2022 12:14:48 -0500 Subject: [PATCH] Add static file-serving example. --- samples/static-files-from-disk/config.capnp | 51 +++++ .../content-dir/index.html | 7 + samples/static-files-from-disk/static.js | 190 ++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 samples/static-files-from-disk/config.capnp create mode 100644 samples/static-files-from-disk/content-dir/index.html create mode 100644 samples/static-files-from-disk/static.js 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}

+ + + ${htmlList.join("\n")} + + +` +}
FilenameModifiedSize