summaryrefslogtreecommitdiff
path: root/node_modules/@11ty/eleventy-dev-server
diff options
context:
space:
mode:
Diffstat (limited to 'node_modules/@11ty/eleventy-dev-server')
-rw-r--r--node_modules/@11ty/eleventy-dev-server/README.md60
-rw-r--r--node_modules/@11ty/eleventy-dev-server/cli.js89
-rw-r--r--node_modules/@11ty/eleventy-dev-server/client/reload-client.js336
-rwxr-xr-xnode_modules/@11ty/eleventy-dev-server/cmd.js77
-rw-r--r--node_modules/@11ty/eleventy-dev-server/package.json57
-rw-r--r--node_modules/@11ty/eleventy-dev-server/server.js1024
-rw-r--r--node_modules/@11ty/eleventy-dev-server/server/ipAddress.js9
-rw-r--r--node_modules/@11ty/eleventy-dev-server/server/wrapResponse.js130
8 files changed, 1782 insertions, 0 deletions
diff --git a/node_modules/@11ty/eleventy-dev-server/README.md b/node_modules/@11ty/eleventy-dev-server/README.md
new file mode 100644
index 0000000..2d74590
--- /dev/null
+++ b/node_modules/@11ty/eleventy-dev-server/README.md
@@ -0,0 +1,60 @@
+<p align="center"><img src="https://www.11ty.dev/img/logo-github.svg" width="200" height="200" alt="11ty Logo"></p>
+
+# eleventy-dev-server 🕚⚡️🎈🐀
+
+A minimal, modern, generic, hot-reloading local web server to help web developers.
+
+## ➡ [Documentation](https://www.11ty.dev/docs/watch-serve/#eleventy-dev-server)
+
+- Please star [Eleventy on GitHub](https://github.com/11ty/eleventy/)!
+- Follow us on Twitter [@eleven_ty](https://twitter.com/eleven_ty)
+- Support [11ty on Open Collective](https://opencollective.com/11ty)
+- [11ty on npm](https://www.npmjs.com/org/11ty)
+- [11ty on GitHub](https://github.com/11ty)
+
+[![npm Version](https://img.shields.io/npm/v/@11ty/eleventy-dev-server.svg?style=for-the-badge)](https://www.npmjs.com/package/@11ty/eleventy-dev-server)
+
+## Installation
+
+This is bundled with `@11ty/eleventy` (and you do not need to install it separately) in Eleventy v2.0.
+
+## CLI
+
+Eleventy Dev Server now also includes a CLI. The CLI is for **standalone** (non-Eleventy) use only: separate installation is unnecessary if you’re using this server with `@11ty/eleventy`.
+
+```sh
+npm install -g @11ty/eleventy-dev-server
+
+# Alternatively, install locally into your project
+npm install @11ty/eleventy-dev-server
+```
+
+This package requires Node 18 or newer.
+
+### CLI Usage
+
+```sh
+# Serve the current directory
+npx @11ty/eleventy-dev-server
+
+# Serve a different subdirectory (also aliased as --input)
+npx @11ty/eleventy-dev-server --dir=_site
+
+# Disable the `domdiff` feature
+npx @11ty/eleventy-dev-server --domdiff=false
+
+# Full command list in the Help
+npx @11ty/eleventy-dev-server --help
+```
+
+## Tests
+
+```
+npm run test
+```
+
+- We use the [ava JavaScript test runner](https://github.com/avajs/ava) ([Assertions documentation](https://github.com/avajs/ava/blob/master/docs/03-assertions.md))
+
+## Changelog
+
+* `v2.0.0` bumps Node.js minimum to 18. \ No newline at end of file
diff --git a/node_modules/@11ty/eleventy-dev-server/cli.js b/node_modules/@11ty/eleventy-dev-server/cli.js
new file mode 100644
index 0000000..04be1ca
--- /dev/null
+++ b/node_modules/@11ty/eleventy-dev-server/cli.js
@@ -0,0 +1,89 @@
+const pkg = require("./package.json");
+const EleventyDevServer = require("./server.js");
+
+const Logger = {
+ info: function(...args) {
+ console.log( "[11ty/eleventy-dev-server]", ...args );
+ },
+ error: function(...args) {
+ console.error( "[11ty/eleventy-dev-server]", ...args );
+ },
+ fatal: function(...args) {
+ Logger.error(...args);
+ process.exitCode = 1;
+ }
+};
+
+Logger.log = Logger.info;
+
+class Cli {
+ static getVersion() {
+ return pkg.version;
+ }
+
+ static getHelp() {
+ return `Usage:
+
+ eleventy-dev-server
+ eleventy-dev-server --dir=_site
+ eleventy-dev-server --port=3000
+
+Arguments:
+
+ --version
+
+ --dir=.
+ Directory to serve (default: \`.\`)
+
+ --input (alias for --dir)
+
+ --port=8080
+ Run the web server on this port (default: \`8080\`)
+ Will autoincrement if already in use.
+
+ --domdiff (enabled, default)
+ --domdiff=false (disabled)
+ Apply HTML changes without a full page reload.
+
+ --help`;
+ }
+
+ static getDefaultOptions() {
+ return {
+ port: "8080",
+ input: ".",
+ domDiff: true,
+ }
+ }
+
+ async serve(options = {}) {
+ this.options = Object.assign(Cli.getDefaultOptions(), options);
+
+ this.server = EleventyDevServer.getServer("eleventy-dev-server-cli", this.options.input, {
+ // TODO allow server configuration extensions
+ showVersion: true,
+ logger: Logger,
+ domDiff: this.options.domDiff,
+
+ // CLI watches all files in the folder by default
+ // this is different from Eleventy usage!
+ watch: [ this.options.input ],
+ });
+
+ this.server.serve(this.options.port);
+
+ // TODO? send any errors here to the server too
+ // with server.sendError({ error });
+ }
+
+ close() {
+ if(this.server) {
+ return this.server.close();
+ }
+ }
+}
+
+module.exports = {
+ Logger,
+ Cli
+}
diff --git a/node_modules/@11ty/eleventy-dev-server/client/reload-client.js b/node_modules/@11ty/eleventy-dev-server/client/reload-client.js
new file mode 100644
index 0000000..fc0ddd1
--- /dev/null
+++ b/node_modules/@11ty/eleventy-dev-server/client/reload-client.js
@@ -0,0 +1,336 @@
+class Util {
+ static pad(num, digits = 2) {
+ let zeroes = new Array(digits + 1).join(0);
+ return `${zeroes}${num}`.slice(-1 * digits);
+ }
+
+ static log(message) {
+ Util.output("log", message);
+ }
+ static error(message, error) {
+ Util.output("error", message, error);
+ }
+ static output(type, ...messages) {
+ let now = new Date();
+ let date = `${Util.pad(now.getUTCHours())}:${Util.pad(
+ now.getUTCMinutes()
+ )}:${Util.pad(now.getUTCSeconds())}.${Util.pad(
+ now.getUTCMilliseconds(),
+ 3
+ )}`;
+ console[type](`[11ty][${date} UTC]`, ...messages);
+ }
+
+ static capitalize(word) {
+ return word.substr(0, 1).toUpperCase() + word.substr(1);
+ }
+
+ static matchRootAttributes(htmlContent) {
+ // Workaround for morphdom bug with attributes on <html> https://github.com/11ty/eleventy-dev-server/issues/6
+ // Note also `childrenOnly: true` above
+ const parser = new DOMParser();
+ let parsed = parser.parseFromString(htmlContent, "text/html");
+ let parsedDoc = parsed.documentElement;
+ let newAttrs = parsedDoc.getAttributeNames();
+
+ let docEl = document.documentElement;
+ // Remove old
+ let removedAttrs = docEl.getAttributeNames().filter(name => !newAttrs.includes(name));
+ for(let attr of removedAttrs) {
+ docEl.removeAttribute(attr);
+ }
+
+ // Add new
+ for(let attr of newAttrs) {
+ docEl.setAttribute(attr, parsedDoc.getAttribute(attr));
+ }
+ }
+
+ static isEleventyLinkNodeMatch(from, to) {
+ // Issue #18 https://github.com/11ty/eleventy-dev-server/issues/18
+ // Don’t update a <link> if the _11ty searchParam is the only thing that’s different
+ if(from.tagName !== "LINK" || to.tagName !== "LINK") {
+ return false;
+ }
+
+ let oldWithoutHref = from.cloneNode();
+ let newWithoutHref = to.cloneNode();
+
+ oldWithoutHref.removeAttribute("href");
+ newWithoutHref.removeAttribute("href");
+
+ // if all other attributes besides href match
+ if(!oldWithoutHref.isEqualNode(newWithoutHref)) {
+ return false;
+ }
+
+ let oldUrl = new URL(from.href);
+ let newUrl = new URL(to.href);
+
+ // morphdom wants to force href="style.css?_11ty" => href="style.css"
+ let paramName = EleventyReload.QUERY_PARAM;
+ let isErasing = oldUrl.searchParams.has(paramName) && !newUrl.searchParams.has(paramName);
+ if(!isErasing) {
+ // not a match if _11ty has a new value (not being erased)
+ return false;
+ }
+
+ oldUrl.searchParams.set(paramName, "");
+ newUrl.searchParams.set(paramName, "");
+
+ // is a match if erasing and the rest of the href matches too
+ return oldUrl.toString() === newUrl.toString();
+ }
+
+ // https://github.com/patrick-steele-idem/morphdom/issues/178#issuecomment-652562769
+ static runScript(source, target) {
+ let script = document.createElement('script');
+
+ // copy over the attributes
+ for(let attr of [...source.attributes]) {
+ script.setAttribute(attr.nodeName ,attr.nodeValue);
+ }
+
+ script.innerHTML = source.innerHTML;
+ (target || source).replaceWith(script);
+ }
+
+ static fullPageReload() {
+ Util.log(`Page reload initiated.`);
+ window.location.reload();
+ }
+}
+
+class EleventyReload {
+ static QUERY_PARAM = "_11ty";
+
+ static reloadTypes = {
+ css: (files, build = {}) => {
+ // Initiate a full page refresh if a CSS change is made but does match any stylesheet url
+ // `build.stylesheets` available in Eleventy v3.0.1-alpha.5+
+ if(Array.isArray(build.stylesheets)) {
+ let match = false;
+ for (let link of document.querySelectorAll(`link[rel="stylesheet"]`)) {
+ if (link.href) {
+ let url = new URL(link.href);
+ if(build.stylesheets.includes(url.pathname)) {
+ match = true;
+ }
+ }
+ }
+
+ if(!match) {
+ Util.fullPageReload();
+ return;
+ }
+ }
+
+ for (let link of document.querySelectorAll(`link[rel="stylesheet"]`)) {
+ if (link.href) {
+ let url = new URL(link.href);
+ url.searchParams.set(this.QUERY_PARAM, Date.now());
+ link.href = url.toString();
+ }
+ }
+
+ Util.log(`CSS updated without page reload.`);
+ },
+ default: async (files, build = {}) => {
+ let morphed = false;
+ let domdiffTemplates = (build?.templates || []).filter(({url, inputPath}) => {
+ return url === document.location.pathname && (files || []).includes(inputPath);
+ });
+
+ if(domdiffTemplates.length === 0) {
+ Util.fullPageReload();
+ return;
+ }
+
+ try {
+ // Important: using `./` allows the `.11ty` folder name to be changed
+ const { default: morphdom } = await import(`./morphdom.js`);
+
+ for (let {url, inputPath, content} of domdiffTemplates) {
+ // Notable limitation: this won’t re-run script elements or JavaScript page lifecycle events (load/DOMContentLoaded)
+ morphed = true;
+
+ morphdom(document.documentElement, content, {
+ childrenOnly: true,
+ onBeforeElUpdated: function (fromEl, toEl) {
+ if (fromEl.nodeName === "SCRIPT" && toEl.nodeName === "SCRIPT") {
+ if(toEl.innerHTML !== fromEl.innerHTML) {
+ Util.log(`JavaScript modified, reload initiated.`);
+ window.location.reload();
+ }
+
+ return false;
+ }
+
+ // Speed-up trick from morphdom docs
+ // https://dom.spec.whatwg.org/#concept-node-equals
+ if (fromEl.isEqualNode(toEl)) {
+ return false;
+ }
+
+ if(Util.isEleventyLinkNodeMatch(fromEl, toEl)) {
+ return false;
+ }
+
+ return true;
+ },
+ addChild: function(parent, child) {
+ // Declarative Shadow DOM https://github.com/11ty/eleventy-dev-server/issues/90
+ if(child.nodeName === "TEMPLATE" && child.hasAttribute("shadowrootmode")) {
+ let root = parent.shadowRoot;
+ if(root) {
+ // remove all shadow root children
+ while(root.firstChild) {
+ root.removeChild(root.firstChild);
+ }
+ }
+ for(let newChild of child.content.childNodes) {
+ root.appendChild(newChild);
+ }
+ } else {
+ parent.appendChild(child);
+ }
+ },
+ onNodeAdded: function (node) {
+ if (node.nodeName === 'SCRIPT') {
+ Util.log(`JavaScript added, reload initiated.`);
+ window.location.reload();
+ }
+ },
+ onElUpdated: function(node) {
+ // Re-attach custom elements
+ if(customElements.get(node.tagName.toLowerCase())) {
+ let placeholder = document.createElement("div");
+ node.replaceWith(placeholder);
+ requestAnimationFrame(() => {
+ placeholder.replaceWith(node);
+ placeholder = undefined;
+ });
+ }
+ }
+ });
+
+ Util.matchRootAttributes(content);
+ Util.log(`HTML delta applied without page reload.`);
+ }
+ } catch(e) {
+ Util.error( "Morphdom error", e );
+ }
+
+ if (!morphed) {
+ Util.fullPageReload();
+ }
+ }
+ }
+
+ constructor() {
+ this.connectionMessageShown = false;
+ this.reconnectEventCallback = this.reconnect.bind(this);
+ }
+
+ init(options = {}) {
+ if (!("WebSocket" in window)) {
+ return;
+ }
+
+ let documentUrl = new URL(document.location.href);
+
+ let reloadPort = new URL(import.meta.url).searchParams.get("reloadPort");
+ if(reloadPort) {
+ documentUrl.port = reloadPort;
+ }
+
+ let { protocol, host } = documentUrl;
+
+ // works with http (ws) and https (wss)
+ let websocketProtocol = protocol.replace("http", "ws");
+
+ let socket = new WebSocket(`${websocketProtocol}//${host}`);
+
+ socket.addEventListener("message", async (event) => {
+ try {
+ let data = JSON.parse(event.data);
+ // Util.log( JSON.stringify(data, null, 2) );
+
+ let { type } = data;
+
+ if (type === "eleventy.reload") {
+ await this.onreload(data);
+ } else if (type === "eleventy.msg") {
+ Util.log(`${data.message}`);
+ } else if (type === "eleventy.error") {
+ // Log Eleventy build errors
+ // Extra parsing for Node Error objects
+ let e = JSON.parse(data.error);
+ Util.error(`Build error: ${e.message}`, e);
+ } else if (type === "eleventy.status") {
+ // Full page reload on initial reconnect
+ if (data.status === "connected" && options.mode === "reconnect") {
+ window.location.reload();
+ }
+
+ if(data.status === "connected") {
+ // With multiple windows, only show one connection message
+ if(!this.isConnected) {
+ Util.log(Util.capitalize(data.status));
+ }
+
+ this.connectionMessageShown = true;
+ } else {
+ if(data.status === "disconnected") {
+ this.addReconnectListeners();
+ }
+
+ Util.log(Util.capitalize(data.status));
+ }
+ } else {
+ Util.log("Unknown event type", data);
+ }
+ } catch (e) {
+ Util.error(`Error parsing ${event.data}: ${e.message}`, e);
+ }
+ });
+
+ socket.addEventListener("open", () => {
+ // no reconnection when the connect is already open
+ this.removeReconnectListeners();
+ });
+
+ socket.addEventListener("close", () => {
+ this.connectionMessageShown = false;
+ this.addReconnectListeners();
+ });
+ }
+
+ reconnect() {
+ Util.log( "Reconnecting…" );
+ this.init({ mode: "reconnect" });
+ }
+
+ async onreload({ subtype, files, build }) {
+ if(!EleventyReload.reloadTypes[subtype]) {
+ subtype = "default";
+ }
+
+ await EleventyReload.reloadTypes[subtype](files, build);
+ }
+
+ addReconnectListeners() {
+ this.removeReconnectListeners();
+
+ window.addEventListener("focus", this.reconnectEventCallback);
+ window.addEventListener("visibilitychange", this.reconnectEventCallback);
+ }
+
+ removeReconnectListeners() {
+ window.removeEventListener("focus", this.reconnectEventCallback);
+ window.removeEventListener("visibilitychange", this.reconnectEventCallback);
+ }
+}
+
+let reloader = new EleventyReload();
+reloader.init(); \ No newline at end of file
diff --git a/node_modules/@11ty/eleventy-dev-server/cmd.js b/node_modules/@11ty/eleventy-dev-server/cmd.js
new file mode 100755
index 0000000..695b845
--- /dev/null
+++ b/node_modules/@11ty/eleventy-dev-server/cmd.js
@@ -0,0 +1,77 @@
+#!/usr/bin/env node
+
+const pkg = require("./package.json");
+
+// Node check
+require("please-upgrade-node")(pkg, {
+ message: function (requiredVersion) {
+ return (
+ "eleventy-dev-server requires Node " +
+ requiredVersion +
+ ". You will need to upgrade Node!"
+ );
+ },
+});
+
+const { Logger, Cli } = require("./cli.js");
+
+const debug = require("debug")("Eleventy:DevServer");
+
+try {
+ const defaults = Cli.getDefaultOptions();
+ for(let key in defaults) {
+ if(key.toLowerCase() !== key) {
+ defaults[key.toLowerCase()] = defaults[key];
+ delete defaults[key];
+ }
+ }
+
+ const argv = require("minimist")(process.argv.slice(2), {
+ string: [
+ "dir",
+ "input", // alias for dir
+ "port",
+ ],
+ boolean: [
+ "version",
+ "help",
+ "domdiff",
+ ],
+ default: defaults,
+ unknown: function (unknownArgument) {
+ throw new Error(
+ `We don’t know what '${unknownArgument}' is. Use --help to see the list of supported commands.`
+ );
+ },
+ });
+
+ debug("command: eleventy-dev-server %o", argv);
+
+ process.on("unhandledRejection", (error, promise) => {
+ Logger.fatal("Unhandled rejection in promise:", promise, error);
+ });
+ process.on("uncaughtException", (error) => {
+ Logger.fatal("Uncaught exception:", error);
+ });
+
+ if (argv.version) {
+ console.log(Cli.getVersion());
+ } else if (argv.help) {
+ console.log(Cli.getHelp());
+ } else {
+ let cli = new Cli();
+
+ cli.serve({
+ input: argv.dir || argv.input,
+ port: argv.port,
+ domDiff: argv.domdiff,
+ });
+
+ process.on("SIGINT", async () => {
+ await cli.close();
+ process.exitCode = 0;
+ });
+ }
+} catch (e) {
+ Logger.fatal("Fatal Error:", e)
+}
diff --git a/node_modules/@11ty/eleventy-dev-server/package.json b/node_modules/@11ty/eleventy-dev-server/package.json
new file mode 100644
index 0000000..e413c7c
--- /dev/null
+++ b/node_modules/@11ty/eleventy-dev-server/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "@11ty/eleventy-dev-server",
+ "version": "2.0.8",
+ "description": "A minimal, modern, generic, hot-reloading local web server to help web developers.",
+ "main": "server.js",
+ "scripts": {
+ "test": "npx ava --verbose",
+ "sample": "node cmd.js --input=test/stubs"
+ },
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/11ty"
+ },
+ "bin": {
+ "eleventy-dev-server": "./cmd.js"
+ },
+ "keywords": [
+ "eleventy",
+ "server",
+ "cli"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "author": {
+ "name": "Zach Leatherman",
+ "email": "zachleatherman@gmail.com",
+ "url": "https://zachleat.com/"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git://github.com/11ty/eleventy-dev-server.git"
+ },
+ "bugs": "https://github.com/11ty/eleventy-dev-server/issues",
+ "homepage": "https://github.com/11ty/eleventy-dev-server/",
+ "dependencies": {
+ "@11ty/eleventy-utils": "^2.0.1",
+ "chokidar": "^3.6.0",
+ "debug": "^4.4.0",
+ "finalhandler": "^1.3.1",
+ "mime": "^3.0.0",
+ "minimist": "^1.2.8",
+ "morphdom": "^2.7.4",
+ "please-upgrade-node": "^3.2.0",
+ "send": "^1.1.0",
+ "ssri": "^11.0.0",
+ "urlpattern-polyfill": "^10.0.0",
+ "ws": "^8.18.1"
+ },
+ "devDependencies": {
+ "ava": "^6.2.0"
+ }
+}
diff --git a/node_modules/@11ty/eleventy-dev-server/server.js b/node_modules/@11ty/eleventy-dev-server/server.js
new file mode 100644
index 0000000..9fbe898
--- /dev/null
+++ b/node_modules/@11ty/eleventy-dev-server/server.js
@@ -0,0 +1,1024 @@
+const path = require("node:path");
+const fs = require("node:fs");
+
+const finalhandler = require("finalhandler");
+const WebSocket = require("ws");
+const { WebSocketServer } = WebSocket;
+const mime = require("mime");
+const ssri = require("ssri");
+const send = require("send");
+const chokidar = require("chokidar");
+const { TemplatePath, isPlainObject } = require("@11ty/eleventy-utils");
+
+const debug = require("debug")("Eleventy:DevServer");
+
+const pkg = require("./package.json");
+const wrapResponse = require("./server/wrapResponse.js");
+const ipAddress = require("./server/ipAddress.js");
+
+if (!globalThis.URLPattern) {
+ require("urlpattern-polyfill");
+}
+
+const DEFAULT_OPTIONS = {
+ port: 8080,
+ reloadPort: false, // Falsy uses same as `port`
+ liveReload: true, // Enable live reload at all
+ showAllHosts: false, // IP address based hosts (other than localhost)
+ injectedScriptsFolder: ".11ty", // Change the name of the special folder used for injected scripts
+ portReassignmentRetryCount: 10, // number of times to increment the port if in use
+ https: {}, // `key` and `cert`, required for http/2 and https
+ domDiff: true, // Use morphdom to apply DOM diffing delta updates to HTML
+ showVersion: false, // Whether or not to show the server version on the command line.
+ encoding: "utf-8", // Default file encoding
+ pathPrefix: "/", // May be overridden by Eleventy, adds a virtual base directory to your project
+ watch: [], // Globs to pass to separate dev server chokidar for watching
+ aliases: {}, // Aliasing feature
+ indexFileName: "index.html", // Allow custom index file name
+ useCache: false, // Use a cache for file contents
+ headers: {}, // Set default response headers
+ messageOnStart: ({ hosts, startupTime, version, options }) => {
+ let hostsStr = " started";
+ if(Array.isArray(hosts) && hosts.length > 0) {
+ // TODO what happens when the cert doesn’t cover non-localhost hosts?
+ hostsStr = ` at ${hosts.join(" or ")}`;
+ }
+
+ return `Server${hostsStr}${options.showVersion ? ` (v${version})` : ""}`;
+ },
+
+ onRequest: {}, // Maps URLPatterns to dynamic callback functions that run on a request from a client.
+
+ // Example:
+ // "/foo/:name": function({ url, pattern, patternGroups }) {
+ // return {
+ // headers: {
+ // "Content-Type": "text/html",
+ // },
+ // body: `${url} ${JSON.stringify(patternGroups)}`
+ // }
+ // }
+
+ // Logger (fancier one is injected by Eleventy)
+ logger: {
+ info: console.log,
+ log: console.log,
+ error: console.error,
+ }
+}
+
+class EleventyDevServer {
+ #watcher;
+ #serverClosing;
+ #serverState;
+ #readyPromise;
+ #readyResolve;
+
+ static getServer(...args) {
+ return new EleventyDevServer(...args);
+ }
+
+ constructor(name, dir, options = {}) {
+ debug("Creating new Dev Server instance.")
+ this.name = name;
+ this.normalizeOptions(options);
+
+ this.fileCache = {};
+ // Directory to serve
+ if(!dir) {
+ throw new Error("Missing `dir` to serve.");
+ }
+ this.dir = dir;
+ this.logger = this.options.logger;
+ this.getWatcher();
+
+ this.#readyPromise = new Promise((resolve) => {
+ this.#readyResolve = resolve;
+ })
+ }
+
+ normalizeOptions(options = {}) {
+ this.options = Object.assign({}, DEFAULT_OPTIONS, options);
+
+ // better names for options https://github.com/11ty/eleventy-dev-server/issues/41
+ if(options.folder !== undefined) {
+ this.options.injectedScriptsFolder = options.folder;
+ delete this.options.folder;
+ }
+ if(options.domdiff !== undefined) {
+ this.options.domDiff = options.domdiff;
+ delete this.options.domdiff;
+ }
+ if(options.enabled !== undefined) {
+ this.options.liveReload = options.enabled;
+ delete this.options.enabled;
+ }
+
+ this.options.pathPrefix = this.cleanupPathPrefix(this.options.pathPrefix);
+ }
+
+ get watcher() {
+ if(this.#watcher) {
+ return this.#watcher;
+ }
+
+ debug("Watching %O", this.options.watch);
+ // TODO if using Eleventy and `watch` option includes output folder (_site) this will trigger two update events!
+ this.#watcher = chokidar.watch(this.options.watch, {
+ // TODO allow chokidar configuration extensions (or re-use the ones in Eleventy)
+
+ ignored: ["**/node_modules/**", ".git"],
+ ignoreInitial: true,
+
+ // same values as Eleventy
+ awaitWriteFinish: {
+ stabilityThreshold: 150,
+ pollInterval: 25,
+ },
+ });
+
+ this.#watcher.on("change", (path) => {
+ this.logger.log( `File changed: ${path} (skips build)` );
+ this.reloadFiles([path]);
+ });
+
+ this.#watcher.on("add", (path) => {
+ this.logger.log( `File added: ${path} (skips build)` );
+ this.reloadFiles([path]);
+ });
+
+ return this.#watcher;
+ }
+
+ getWatcher() {
+ // only initialize watcher if watcher via getWatcher if has targets
+ // this.watcher in watchFiles() is a manual workaround
+ if(this.options.watch.length > 0) {
+ return this.watcher;
+ }
+ }
+
+ watchFiles(files) {
+ if(Array.isArray(files) && files.length > 0) {
+ files = files.map(entry => TemplatePath.stripLeadingDotSlash(entry));
+
+ debug("Also watching %O", files);
+ this.watcher.add(files);
+ }
+ }
+
+ cleanupPathPrefix(pathPrefix) {
+ if(!pathPrefix || pathPrefix === "/") {
+ return "/";
+ }
+ if(!pathPrefix.startsWith("/")) {
+ pathPrefix = `/${pathPrefix}`
+ }
+ if(!pathPrefix.endsWith("/")) {
+ pathPrefix = `${pathPrefix}/`;
+ }
+ return pathPrefix;
+ }
+
+ // Allowed list of files that can be served from outside `dir`
+ setAliases(aliases) {
+ if(aliases) {
+ this.passthroughAliases = aliases;
+ debug( "Setting aliases (emulated passthrough copy) %O", aliases );
+ }
+ }
+
+ matchPassthroughAlias(url) {
+ let aliases = Object.assign({}, this.options.aliases, this.passthroughAliases);
+ for(let targetUrl in aliases) {
+ if(!targetUrl) {
+ continue;
+ }
+
+ let file = aliases[targetUrl];
+ if(url.startsWith(targetUrl)) {
+ let inputDirectoryPath = file + url.slice(targetUrl.length);
+
+ // e.g. addPassthroughCopy("img/") but <img src="/img/built/IdthKOzqFA-350.png">
+ // generated by the image plugin (written to the output folder)
+ // If they do not exist in the input directory, this will fallback to the output directory.
+ if(fs.existsSync(inputDirectoryPath)) {
+ return inputDirectoryPath;
+ }
+ }
+ }
+ return false;
+ }
+
+ isFileInDirectory(dir, file) {
+ let absoluteDir = TemplatePath.absolutePath(dir);
+ let absoluteFile = TemplatePath.absolutePath(file);
+ return absoluteFile.startsWith(absoluteDir);
+ }
+
+ getOutputDirFilePath(filepath, filename = "") {
+ let computedPath;
+ if(filename === ".html") {
+ // avoid trailing slash for filepath/.html requests
+ let prefix = path.join(this.dir, filepath);
+ if(prefix.endsWith(path.sep)) {
+ prefix = prefix.substring(0, prefix.length - path.sep.length);
+ }
+ computedPath = prefix + filename;
+ } else {
+ computedPath = path.join(this.dir, filepath, filename);
+ }
+
+ computedPath = decodeURIComponent(computedPath);
+
+ if(!filename) { // is a direct URL request (not an implicit .html or index.html add)
+ let alias = this.matchPassthroughAlias(filepath);
+
+ if(alias) {
+ if(!this.isFileInDirectory(path.resolve("."), alias)) {
+ throw new Error("Invalid path");
+ }
+
+ return alias;
+ }
+ }
+
+ // Check that the file is in the output path (error if folks try use `..` in the filepath)
+ if(!this.isFileInDirectory(this.dir, computedPath)) {
+ throw new Error("Invalid path");
+ }
+
+ return computedPath;
+ }
+
+ isOutputFilePathExists(rawPath) {
+ return fs.existsSync(rawPath) && !TemplatePath.isDirectorySync(rawPath);
+ }
+
+ /* Use conventions documented here https://www.zachleat.com/web/trailing-slash/
+ * resource.html exists:
+ * /resource matches
+ * /resource/ redirects to /resource
+ * resource/index.html exists:
+ * /resource redirects to /resource/
+ * /resource/ matches
+ * both resource.html and resource/index.html exists:
+ * /resource matches /resource.html
+ * /resource/ matches /resource/index.html
+ */
+ mapUrlToFilePath(url) {
+ // Note: `localhost` is not important here, any host would work
+ let u = new URL(url, "http://localhost/");
+ url = u.pathname;
+
+ // Remove PathPrefix from start of URL
+ if (this.options.pathPrefix !== "/") {
+ // Requests to root should redirect to new pathPrefix
+ if(url === "/") {
+ return {
+ statusCode: 302,
+ url: this.options.pathPrefix,
+ }
+ }
+
+ // Requests to anything outside of root should fail with 404
+ if (!url.startsWith(this.options.pathPrefix)) {
+ return {
+ statusCode: 404,
+ };
+ }
+
+ url = url.slice(this.options.pathPrefix.length - 1);
+ }
+
+ let rawPath = this.getOutputDirFilePath(url);
+ if (this.isOutputFilePathExists(rawPath)) {
+ return {
+ statusCode: 200,
+ filepath: rawPath,
+ };
+ }
+
+ let indexHtmlPath = this.getOutputDirFilePath(url, this.options.indexFileName);
+ let indexHtmlExists = fs.existsSync(indexHtmlPath);
+
+ let htmlPath = this.getOutputDirFilePath(url, ".html");
+ let htmlExists = fs.existsSync(htmlPath);
+
+ // /resource/ => /resource/index.html
+ if (indexHtmlExists && url.endsWith("/")) {
+ return {
+ statusCode: 200,
+ filepath: indexHtmlPath,
+ };
+ }
+ // /resource => resource.html
+ if (htmlExists && !url.endsWith("/")) {
+ return {
+ statusCode: 200,
+ filepath: htmlPath,
+ };
+ }
+
+ // /resource => redirect to /resource/
+ if (indexHtmlExists && !url.endsWith("/")) {
+ return {
+ statusCode: 301,
+ url: u.pathname + "/",
+ };
+ }
+
+ // /resource/ => redirect to /resource
+ if (htmlExists && url.endsWith("/")) {
+ return {
+ statusCode: 301,
+ url: u.pathname.substring(0, u.pathname.length - 1),
+ };
+ }
+
+ return {
+ statusCode: 404,
+ };
+ }
+
+ #readFile(filepath) {
+ if(this.options.useCache && this.fileCache[filepath]) {
+ return this.fileCache[filepath];
+ }
+
+ let contents = fs.readFileSync(filepath, {
+ encoding: this.options.encoding,
+ });
+
+ if(this.options.useCache) {
+ this.fileCache[filepath] = contents;
+ }
+
+ return contents;
+ }
+
+ #getFileContents(localpath, rootDir) {
+ let filepath;
+ let searchLocations = [];
+
+ if(rootDir) {
+ searchLocations.push(TemplatePath.absolutePath(rootDir, localpath));
+ }
+
+ // fallbacks for file:../ installations
+ searchLocations.push(TemplatePath.absolutePath(__dirname, localpath));
+ searchLocations.push(TemplatePath.absolutePath(__dirname, "../../../", localpath));
+
+ for(let loc of searchLocations) {
+ if(fs.existsSync(loc)) {
+ filepath = loc;
+ break;
+ }
+ }
+
+ return this.#readFile(filepath);
+ }
+
+ augmentContentWithNotifier(content, inlineContents = false, options = {}) {
+ let { integrityHash, scriptContents } = options;
+ if(!scriptContents) {
+ scriptContents = this.#getFileContents("./client/reload-client.js");
+ }
+ if(!integrityHash) {
+ integrityHash = ssri.fromData(scriptContents);
+ }
+
+ let searchParams = new URLSearchParams();
+ if(this.options.reloadPort) {
+ searchParams.set("reloadPort", this.options.reloadPort);
+ }
+
+ let searchParamsStr = searchParams.size > 0 ? `?${searchParams.toString()}` : "";
+
+ // This isn’t super necessary because it’s a local file, but it’s included anyway
+ let script = `<script type="module" integrity="${integrityHash}"${inlineContents ? `>${scriptContents}` : ` src="/${this.options.injectedScriptsFolder}/reload-client.js${searchParamsStr}">`}</script>`;
+
+ if (content.includes("</head>")) {
+ return content.replace("</head>", `${script}</head>`);
+ }
+
+ // If the HTML document contains an importmap, insert the module script after the importmap element
+ let importMapRegEx = /<script type=\\?importmap\\?[^>]*>(\n|.)*?<\/script>/gmi;
+ let importMapMatch = content.match(importMapRegEx)?.[0];
+
+ if (importMapMatch) {
+ return content.replace(importMapMatch, `${importMapMatch}${script}`);
+ }
+
+ // <title> is the only *required* element in an HTML document
+ if (content.includes("</title>")) {
+ return content.replace("</title>", `</title>${script}`);
+ }
+
+ // If you’ve reached this section, your HTML is invalid!
+ // We want to be super forgiving here, because folks might be in-progress editing the document!
+ if (content.includes("</body>")) {
+ return content.replace("</body>", `${script}</body>`);
+ }
+ if (content.includes("</html>")) {
+ return content.replace("</html>", `${script}</html>`);
+ }
+ if (content.includes("<!doctype html>")) {
+ return content.replace("<!doctype html>", `<!doctype html>${script}`);
+ }
+
+ // Notably, works without content at all!!
+ return (content || "") + script;
+ }
+
+ getFileContentType(filepath, res) {
+ let contentType = res.getHeader("Content-Type");
+
+ // Content-Type might be already set via middleware
+ if (contentType) {
+ return contentType;
+ }
+
+ let mimeType = mime.getType(filepath);
+ if (!mimeType) {
+ return;
+ }
+
+ contentType = mimeType;
+
+ // We only want to append charset if the header is not already set
+ if (contentType === "text/html") {
+ contentType = `text/html; charset=${this.options.encoding}`;
+ }
+
+ return contentType;
+ }
+
+ renderFile(filepath, res) {
+ let contents = fs.readFileSync(filepath);
+ let contentType = this.getFileContentType(filepath, res);
+
+ for(const [key, value] of Object.entries(this.options.headers)){
+ res.setHeader(key, value);
+ }
+
+ if (!contentType) {
+ return res.end(contents);
+ }
+
+ res.setHeader("Content-Type", contentType);
+
+ if (contentType.startsWith("text/html")) {
+ // the string is important here, wrapResponse expects strings internally for HTML content (for now)
+ return res.end(contents.toString());
+ }
+
+ return res.end(contents);
+ }
+
+ async eleventyDevServerMiddleware(req, res, next) {
+ if(this.#serverState === "CLOSING") {
+ return res.end("");
+ }
+
+ for(let urlPatternString in this.options.onRequest) {
+ let fn = this.options.onRequest[urlPatternString];
+ let fullPath = this.getServerPath(urlPatternString);
+ let p = new URLPattern({ pathname: fullPath });
+
+ // request url should already include pathprefix.
+ let fullUrl = this.getServerUrlRaw("localhost", req.url);
+ let match = p.exec(fullUrl);
+
+ let u = new URL(fullUrl);
+
+ if(match) {
+ let result = await fn({
+ url: u,
+ pattern: p,
+ patternGroups: match?.pathname?.groups || {},
+ });
+
+ if(!result && result !== "") {
+ continue;
+ }
+
+ if(typeof result === "string") {
+ return res.end(result);
+ }
+
+ if(isPlainObject(result) || result instanceof Response) {
+ if(typeof result.status === "number") {
+ res.statusCode = result.status;
+ }
+
+ if(result.headers instanceof Headers) {
+ for(let [key, value] of result.headers.entries()) {
+ res.setHeader(key, value);
+ }
+ } else if(isPlainObject(result.headers)) {
+ for(let key of Object.keys(result.headers)) {
+ res.setHeader(key, result.headers[key]);
+ }
+ }
+
+ if(result instanceof Response) {
+ // no gzip/br compression here, uncompressed from fetch https://github.com/w3c/ServiceWorker/issues/339
+ res.removeHeader("content-encoding");
+
+ let arrayBuffer = await result.arrayBuffer();
+ res.setHeader("content-length", arrayBuffer.byteLength);
+
+ let buffer = Buffer.from(arrayBuffer);
+ return res.end(buffer);
+ }
+
+ return res.end(result.body || "");
+ }
+
+ throw new Error(`Invalid return type from \`onRequest\` pattern for ${urlPatternString}: expected string, object literal, or Response instance.`);
+ }
+ } // end onRequest
+
+ if(req.url.startsWith(`/${this.options.injectedScriptsFolder}/reload-client.js`)) {
+ if(this.options.liveReload) {
+ res.setHeader("Content-Type", mime.getType("js"));
+ return res.end(this.#getFileContents("./client/reload-client.js"));
+ }
+ } else if(req.url === `/${this.options.injectedScriptsFolder}/morphdom.js`) {
+ if(this.options.domDiff) {
+ res.setHeader("Content-Type", mime.getType("js"));
+ let morphdomEsmPath = require.resolve("morphdom").replace("morphdom.js", "morphdom-esm.js");
+ return res.end(this.#readFile(morphdomEsmPath));
+ }
+ }
+
+ next();
+ }
+
+ // This runs at the end of the middleware chain
+ eleventyProjectMiddleware(req, res) {
+ // Known issue with `finalhandler` and HTTP/2:
+ // UnsupportedWarning: Status message is not supported by HTTP/2 (RFC7540 8.1.2.4)
+ // https://github.com/pillarjs/finalhandler/pull/34
+
+ let lastNext = finalhandler(req, res, {
+ onerror: (e) => {
+ if (e.statusCode === 404) {
+ let localPath = TemplatePath.stripLeadingSubPath(
+ e.path,
+ TemplatePath.absolutePath(this.dir)
+ );
+ this.logger.error(
+ `HTTP ${e.statusCode}: Template not found in output directory (${this.dir}): ${localPath}`
+ );
+ } else {
+ this.logger.error(`HTTP ${e.statusCode}: ${e.message}`);
+ }
+ },
+ });
+
+ // middleware (maybe a serverless request) already set a body upstream, skip this part
+ if(!res._shouldForceEnd) {
+ let match = this.mapUrlToFilePath(req.url);
+ debug( req.url, match );
+
+ if (match) {
+ if (match.statusCode === 200 && match.filepath) {
+ // Content-Range request, probably Safari trying to stream video
+ if (req.headers.range) {
+ return send(req, match.filepath).pipe(res);
+ }
+
+ return this.renderFile(match.filepath, res);
+ }
+
+ // Redirects, usually for trailing slash to .html stuff
+ if (match.url) {
+ res.statusCode = match.statusCode;
+ res.setHeader("Location", match.url);
+ return res.end();
+ }
+
+ let raw404Path = this.getOutputDirFilePath("404.html");
+ if(match.statusCode === 404 && this.isOutputFilePathExists(raw404Path)) {
+ res.statusCode = match.statusCode;
+ res.isCustomErrorPage = true;
+ return this.renderFile(raw404Path, res);
+ }
+ }
+ }
+
+ if(res.body && !res.bodyUsed) {
+ if(res._shouldForceEnd) {
+ res.end();
+ } else {
+ let err = new Error("A response was never written to the stream. Are you missing a server middleware with `res.end()`?");
+ err.statusCode = 500;
+ lastNext(err);
+ return;
+ }
+ }
+
+ lastNext();
+ }
+
+ async onRequestHandler (req, res) {
+ res = wrapResponse(res, content => {
+
+ // check to see if this is a client fetch and not a navigation
+ let isXHR = req.headers["sec-fetch-mode"] && req.headers["sec-fetch-mode"] != "navigate";
+
+ if(this.options.liveReload !== false && !isXHR) {
+ let scriptContents = this.#getFileContents("./client/reload-client.js");
+ let integrityHash = ssri.fromData(scriptContents);
+
+ // Bare (not-custom) finalhandler error pages have a Content-Security-Policy `default-src 'none'` that
+ // prevents the client script from executing, so we override it
+ if(res.statusCode !== 200 && !res.isCustomErrorPage) {
+ res.setHeader("Content-Security-Policy", `script-src '${integrityHash}'`);
+ }
+ return this.augmentContentWithNotifier(content, res.statusCode !== 200, {
+ scriptContents,
+ integrityHash
+ });
+ }
+
+ return content;
+ });
+
+ let middlewares = this.options.middleware || [];
+ middlewares = middlewares.slice();
+
+ // TODO because this runs at the very end of the middleware chain,
+ // if we move the static stuff up in the order we could use middleware to modify
+ // the static content in middleware!
+ middlewares.push(this.eleventyProjectMiddleware);
+ middlewares.reverse();
+
+ // Runs very first in the middleware chain
+ middlewares.push(this.eleventyDevServerMiddleware);
+
+ let bound = [];
+ let next;
+
+ for(let ware of middlewares) {
+ let fn;
+ if(next) {
+ fn = ware.bind(this, req, res, next);
+ } else {
+ fn = ware.bind(this, req, res);
+ }
+ bound.push(fn);
+ next = fn;
+ }
+
+ bound.reverse();
+
+ let [first] = bound;
+ await first();
+ }
+
+ getHosts() {
+ let hosts = new Set();
+ if(this.options.showAllHosts) {
+ for(let host of ipAddress()) {
+ hosts.add(this.getServerUrl(host));
+ }
+ }
+ hosts.add(this.getServerUrl("localhost"));
+ return Array.from(hosts);
+ }
+
+ get server() {
+ if (this._server) {
+ return this._server;
+ }
+
+ this.start = Date.now();
+
+ // Check for secure server requirements, otherwise use HTTP
+ let { key, cert } = this.options.https;
+ if(key && cert) {
+ const { createSecureServer } = require("http2");
+
+ let options = {
+ allowHTTP1: true,
+
+ // Credentials
+ key: fs.readFileSync(key),
+ cert: fs.readFileSync(cert),
+ };
+ this._server = createSecureServer(options, this.onRequestHandler.bind(this));
+ this._serverProtocol = "https:";
+ } else {
+ const { createServer } = require("http");
+
+ this._server = createServer(this.onRequestHandler.bind(this));
+ this._serverProtocol = "http:";
+ }
+
+ this.portRetryCount = 0;
+ this._server.on("error", (err) => {
+ if (err.code == "EADDRINUSE") {
+ if (this.portRetryCount < this.options.portReassignmentRetryCount) {
+ this.portRetryCount++;
+ debug(
+ "Server already using port %o, trying the next port %o. Retry number %o of %o",
+ err.port,
+ err.port + 1,
+ this.portRetryCount,
+ this.options.portReassignmentRetryCount
+ );
+ this._serverListen(err.port + 1);
+ } else {
+ throw new Error(
+ `Tried ${this.options.portReassignmentRetryCount} different ports but they were all in use. You can a different starter port using --port on the command line.`
+ );
+ }
+ } else {
+ this._serverErrorHandler(err);
+ }
+ });
+
+ this._server.on("listening", (e) => {
+ this.setupReloadNotifier();
+
+ let logMessageCallback = typeof this.options.messageOnStart === "function" ? this.options.messageOnStart : () => false;
+ let hosts = this.getHosts();
+ let message = logMessageCallback({
+ hosts,
+ localhostUrl: this.getServerUrl("localhost"),
+ options: this.options,
+ version: pkg.version,
+ startupTime: Date.now() - this.start,
+ });
+
+ if(message) {
+ this.logger.info(message);
+ }
+
+ this.#readyResolve();
+ });
+
+ return this._server;
+ }
+
+ async ready() {
+ return this.#readyPromise;
+ }
+
+ _serverListen(port) {
+ this.server.listen({
+ port,
+ });
+ }
+
+ getServerPath(pathname) {
+ // duplicate slashes
+ if(this.options.pathPrefix.endsWith("/") && pathname.startsWith("/")) {
+ pathname = pathname.slice(1);
+ }
+ return `${this.options.pathPrefix}${pathname}`;
+ }
+
+ getServerUrlRaw(host, pathname = "", isRaw = true) {
+ if(!this._server || !this._serverProtocol) {
+ throw new Error("Access to server url not yet available.");
+ }
+
+ let address = this._server.address();
+ if(!address?.port) {
+ throw new Error("Access to server port not yet available.");
+ }
+
+ return `${this._serverProtocol}//${host}:${address.port}${isRaw ? pathname : this.getServerPath(pathname)}`;
+ }
+
+ getServerUrl(host, pathname = "") {
+ return this.getServerUrlRaw(host, pathname, false);
+ }
+
+ async getPort() {
+ return new Promise(resolve => {
+ this.server.on("listening", (e) => {
+ let { port } = this._server.address();
+ resolve(port);
+ });
+ })
+ }
+
+ serve(port) {
+ this.getWatcher();
+
+ this._serverListen(port);
+ }
+
+ _serverErrorHandler(err) {
+ if (err.code == "EADDRINUSE") {
+ this.logger.error(`Server error: Port in use ${err.port}`);
+ } else {
+ this.logger.error(`Server error: ${err.message}`);
+ }
+ }
+
+ // Websocket Notifications
+ setupReloadNotifier() {
+ let options = {};
+ if(this.options.reloadPort) {
+ options.port = this.options.reloadPort;
+ } else {
+ // includes the port
+ options.server = this.server;
+ }
+
+ let updateServer = new WebSocketServer(options);
+
+ updateServer.on("connection", (ws) => {
+ this.sendUpdateNotification({
+ type: "eleventy.status",
+ status: "connected",
+ });
+ });
+
+ updateServer.on("error", (err) => {
+ this._serverErrorHandler(err);
+ });
+
+ this.updateServer = updateServer;
+ }
+
+ // Broadcasts to all open browser windows
+ sendUpdateNotification(obj) {
+ if(!this.updateServer?.clients) {
+ return;
+ }
+
+ for(let client of this.updateServer.clients) {
+ if (client.readyState === WebSocket.OPEN) {
+ client.send(JSON.stringify(obj));
+ }
+ }
+ }
+
+ // Helper for promisifying close methods with callbacks, like http.Server or ws.WebSocketServer.
+ async _closeServer(server) {
+ return new Promise((resolve, reject) => {
+ server.close(err => {
+ if (err) {
+ reject(err);
+ }
+ resolve();
+ });
+
+ // Note: this method won’t exist for updateServer
+ if("closeAllConnections" in server) {
+ // Node 18.2+
+ server.closeAllConnections();
+ }
+ });
+ }
+
+ async close() {
+ // Prevent multiple invocations.
+ if (this.#serverClosing) {
+ return this.#serverClosing;
+ }
+
+ // TODO would be awesome to set a delayed redirect when port changed to redirect to new _server_
+ this.sendUpdateNotification({
+ type: "eleventy.status",
+ status: "disconnected",
+ });
+
+ let promises = []
+ if(this.updateServer) {
+ // Close all existing WS connections.
+ this.updateServer?.clients.forEach(socket => socket.close());
+ promises.push(this._closeServer(this.updateServer));
+ }
+
+ if(this._server?.listening) {
+ promises.push(this._closeServer(this.server));
+ }
+
+ if(this.#watcher) {
+ promises.push(this.#watcher.close());
+ this.#watcher = undefined;
+ }
+
+ this.#serverClosing = Promise.all(promises).then(() => {
+ this.#serverState = "CLOSED";
+ this.#serverClosing = undefined;
+ });
+
+ this.#serverState = "CLOSING";
+
+ return this.#serverClosing;
+ }
+
+ sendError({ error }) {
+ this.sendUpdateNotification({
+ type: "eleventy.error",
+ // Thanks https://stackoverflow.com/questions/18391212/is-it-not-possible-to-stringify-an-error-using-json-stringify
+ error: JSON.stringify(error, Object.getOwnPropertyNames(error)),
+ });
+ }
+
+ // reverse of mapUrlToFilePath
+ // /resource/ <= /resource/index.html
+ // /resource <= resource.html
+ getUrlsFromFilePath(path) {
+ if(this.dir === ".") {
+ path = `/${path}`
+ } else {
+ path = path.slice(this.dir.length);
+ }
+
+ let urls = [];
+ urls.push(path);
+
+ if(path.endsWith(`/${this.options.indexFileName}`)) {
+ urls.push(path.slice(0, -1 * this.options.indexFileName.length));
+ } else if(path.endsWith(".html")) {
+ urls.push(path.slice(0, -1 * ".html".length));
+ }
+
+ return urls;
+ }
+
+ // returns [{ url, inputPath, content }]
+ getBuildTemplatesFromFilePath(path) {
+ // We can skip this for non-html files, dom-diffing will not apply
+ if(!path.endsWith(".html")) {
+ return [];
+ }
+
+ let urls = this.getUrlsFromFilePath(path);
+ let obj = {
+ inputPath: path,
+ content: fs.readFileSync(path, "utf8"),
+ }
+
+ return urls.map(url => {
+ return Object.assign({ url }, obj);
+ });
+ }
+
+ reloadFiles(files, useDomDiffingForHtml = true) {
+ if(!Array.isArray(files)) {
+ throw new Error("reloadFiles method requires an array of file paths.");
+ }
+
+ let subtype;
+ if(!files.some((entry) => !entry.endsWith(".css"))) {
+ // only if all changes are css changes
+ subtype = "css";
+ }
+
+ let templates = [];
+ if(useDomDiffingForHtml && this.options.domDiff) {
+ for(let filePath of files) {
+ if(!filePath.endsWith(".html")) {
+ continue;
+ }
+ for(let templateEntry of this.getBuildTemplatesFromFilePath(filePath)) {
+ templates.push(templateEntry);
+ }
+ }
+ }
+
+ this.reload({
+ files,
+ subtype,
+ build: {
+ templates
+ }
+ });
+ }
+
+ reload(event = {}) {
+ let { subtype, files, build } = event;
+ if (build?.templates) {
+ build.templates = build.templates
+ .filter(entry => {
+ if(!this.options.domDiff) {
+ // Don’t include any files if the dom diffing option is disabled
+ return false;
+ }
+
+ // Filter to only include watched templates that were updated
+ return (files || []).includes(entry.inputPath);
+ });
+ }
+
+ this.sendUpdateNotification({
+ type: "eleventy.reload",
+ subtype,
+ files,
+ build,
+ });
+ }
+}
+
+module.exports = EleventyDevServer;
diff --git a/node_modules/@11ty/eleventy-dev-server/server/ipAddress.js b/node_modules/@11ty/eleventy-dev-server/server/ipAddress.js
new file mode 100644
index 0000000..aaddbee
--- /dev/null
+++ b/node_modules/@11ty/eleventy-dev-server/server/ipAddress.js
@@ -0,0 +1,9 @@
+const os = require("node:os");
+
+const INTERFACE_FAMILIES = ["IPv4"];
+
+module.exports = function() {
+ return Object.values(os.networkInterfaces()).flat().filter(interface => {
+ return interface.internal === false && INTERFACE_FAMILIES.includes(interface.family);
+ }).map(interface => interface.address);
+}; \ No newline at end of file
diff --git a/node_modules/@11ty/eleventy-dev-server/server/wrapResponse.js b/node_modules/@11ty/eleventy-dev-server/server/wrapResponse.js
new file mode 100644
index 0000000..5161904
--- /dev/null
+++ b/node_modules/@11ty/eleventy-dev-server/server/wrapResponse.js
@@ -0,0 +1,130 @@
+function getContentType(headers) {
+ if(!headers) {
+ return;
+ }
+
+ for(let key in headers) {
+ if(key.toLowerCase() === "content-type") {
+ return headers[key];
+ }
+ }
+}
+
+// Inspired by `resp-modifier` https://github.com/shakyShane/resp-modifier/blob/4a000203c9db630bcfc3b6bb8ea2abc090ae0139/index.js
+function wrapResponse(resp, transformHtml) {
+ resp._wrappedOriginalWrite = resp.write;
+ resp._wrappedOriginalWriteHead = resp.writeHead;
+ resp._wrappedOriginalEnd = resp.end;
+
+ resp._wrappedHeaders = [];
+ resp._wrappedTransformHtml = transformHtml;
+ resp._hasEnded = false;
+ resp._shouldForceEnd = false;
+
+ // Compatibility with web standards Response()
+ Object.defineProperty(resp, "body", {
+ // Returns write cache
+ get: function() {
+ if(typeof this._writeCache === "string") {
+ return this._writeCache;
+ }
+ },
+ // Usage:
+ // res.body = ""; // overwrite existing content
+ // res.body += ""; // append to existing content, can also res.write("") to append
+ set: function(data) {
+ if(typeof data === "string") {
+ this._writeCache = data;
+ }
+ }
+ });
+
+ // Compatibility with web standards Response()
+ Object.defineProperty(resp, "bodyUsed", {
+ get: function() {
+ return this._hasEnded;
+ }
+ })
+
+ // Original signature writeHead(statusCode[, statusMessage][, headers])
+ resp.writeHead = function(statusCode, ...args) {
+ let headers = args[args.length - 1];
+ // statusMessage is a string
+ if(typeof headers !== "string") {
+ this._contentType = getContentType(headers);
+ }
+
+ if((this._contentType || "").startsWith("text/html")) {
+ this._wrappedHeaders.push([statusCode, ...args]);
+ } else {
+ return this._wrappedOriginalWriteHead(statusCode, ...args);
+ }
+ return this;
+ }
+
+ // data can be a String or Buffer
+ resp.write = function(data, ...args) {
+ if(typeof data === "string") {
+ if(!this._writeCache) {
+ this._writeCache = "";
+ }
+
+ // TODO encoding and callback args
+ this._writeCache += data;
+ } else {
+ // Buffers
+ return this._wrappedOriginalWrite(data, ...args);
+ }
+ return this;
+ }
+
+ // data can be a String or Buffer
+ resp.end = function(data, encoding, callback) {
+ resp._hasEnded = true;
+
+ if(typeof this._writeCache === "string" || typeof data === "string") {
+ // Strings
+ if(!this._writeCache) {
+ this._writeCache = "";
+ }
+ if(typeof data === "string") {
+ this._writeCache += data;
+ }
+
+ let result = this._writeCache;
+
+ // Only transform HTML
+ // Note the “setHeader versus writeHead” note on https://nodejs.org/api/http.html#responsewriteheadstatuscode-statusmessage-headers
+ let contentType = this._contentType || getContentType(this.getHeaders());
+ if(contentType?.startsWith("text/html")) {
+ if(this._wrappedTransformHtml && typeof this._wrappedTransformHtml === "function") {
+ result = this._wrappedTransformHtml(result);
+ // uncompressed size: https://github.com/w3c/ServiceWorker/issues/339
+ this.setHeader("Content-Length", Buffer.byteLength(result));
+ }
+ }
+
+ for(let headers of this._wrappedHeaders) {
+ this._wrappedOriginalWriteHead(...headers);
+ }
+
+ this._writeCache = [];
+ this._wrappedOriginalWrite(result, encoding)
+ return this._wrappedOriginalEnd(callback);
+ } else {
+ // Buffer or Uint8Array
+ for(let headers of this._wrappedHeaders) {
+ this._wrappedOriginalWriteHead(...headers);
+ }
+
+ if(data) {
+ this._wrappedOriginalWrite(data, encoding);
+ }
+ return this._wrappedOriginalEnd(callback);
+ }
+ }
+
+ return resp;
+}
+
+module.exports = wrapResponse; \ No newline at end of file