├── .github
└── workflows
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── .npmignore
├── README.md
├── cli.js
├── client
└── reload-client.js
├── cmd.js
├── package.json
├── server.js
├── server
├── ipAddress.js
└── wrapResponse.js
└── test
├── sample
└── test’s
│ └── sample.txt
├── stubs
├── alternative
│ └── test
│ │ └── .gitkeep
├── custom-index.html
├── html-with-js.html
├── index.html
├── index.php
├── petite-vue.html
├── route space.html
├── route1
│ ├── custom-index.html
│ └── index.html
├── route2.html
├── route3.html
├── route3
│ └── index.html
├── sample.html
├── with-css
│ ├── index.html
│ └── style.css
├── with-import-map.html
└── zach’s.html
├── testServer.js
└── testServerRequests.js
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Node Unit Tests
2 | on:
3 | push:
4 | branches-ignore:
5 | - "gh-pages"
6 | jobs:
7 | build:
8 | runs-on: ${{ matrix.os }}
9 | strategy:
10 | matrix:
11 | os: ["ubuntu-latest", "macos-latest", "windows-latest"]
12 | node: ["18", "20", "22"]
13 | name: Node.js ${{ matrix.node }} on ${{ matrix.os }}
14 | steps:
15 | - uses: actions/checkout@v4
16 | - name: Setup node
17 | uses: actions/setup-node@v4
18 | with:
19 | node-version: ${{ matrix.node }}
20 | # cache: npm
21 | - run: npm install
22 | - run: npm test
23 | env:
24 | YARN_GPG: no
25 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Publish Release to npm
2 | on:
3 | release:
4 | types: [published]
5 | permissions: read-all
6 | jobs:
7 | build:
8 | runs-on: ubuntu-latest
9 | permissions:
10 | contents: read
11 | id-token: write
12 | steps:
13 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # 4.1.7
14 | - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # 4.0.3
15 | with:
16 | node-version: "20"
17 | registry-url: 'https://registry.npmjs.org'
18 | - run: npm install
19 | - run: npm test
20 | - if: ${{ github.event.release.tag_name != '' && env.NPM_PUBLISH_TAG != '' }}
21 | run: npm publish --provenance --access=public --tag=${{ env.NPM_PUBLISH_TAG }}
22 | env:
23 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
24 | NPM_PUBLISH_TAG: ${{ contains(github.event.release.tag_name, '-beta.') && 'beta' || 'latest' }}
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | package-lock.json
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | test
2 | .*
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |

2 |
3 | # eleventy-dev-server 🕚⚡️🎈🐀
4 |
5 | A minimal, modern, generic, hot-reloading local web server to help web developers.
6 |
7 | ## ➡ [Documentation](https://www.11ty.dev/docs/watch-serve/#eleventy-dev-server)
8 |
9 | - Please star [Eleventy on GitHub](https://github.com/11ty/eleventy/)!
10 | - Follow us on Twitter [@eleven_ty](https://twitter.com/eleven_ty)
11 | - Support [11ty on Open Collective](https://opencollective.com/11ty)
12 | - [11ty on npm](https://www.npmjs.com/org/11ty)
13 | - [11ty on GitHub](https://github.com/11ty)
14 |
15 | [](https://www.npmjs.com/package/@11ty/eleventy-dev-server)
16 |
17 | ## Installation
18 |
19 | This is bundled with `@11ty/eleventy` (and you do not need to install it separately) in Eleventy v2.0.
20 |
21 | ## CLI
22 |
23 | 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`.
24 |
25 | ```sh
26 | npm install -g @11ty/eleventy-dev-server
27 |
28 | # Alternatively, install locally into your project
29 | npm install @11ty/eleventy-dev-server
30 | ```
31 |
32 | This package requires Node 18 or newer.
33 |
34 | ### CLI Usage
35 |
36 | ```sh
37 | # Serve the current directory
38 | npx @11ty/eleventy-dev-server
39 |
40 | # Serve a different subdirectory (also aliased as --input)
41 | npx @11ty/eleventy-dev-server --dir=_site
42 |
43 | # Disable the `domdiff` feature
44 | npx @11ty/eleventy-dev-server --domdiff=false
45 |
46 | # Full command list in the Help
47 | npx @11ty/eleventy-dev-server --help
48 | ```
49 |
50 | ## Tests
51 |
52 | ```
53 | npm run test
54 | ```
55 |
56 | - 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))
57 |
58 | ## Changelog
59 |
60 | * `v2.0.0` bumps Node.js minimum to 18.
--------------------------------------------------------------------------------
/cli.js:
--------------------------------------------------------------------------------
1 | const pkg = require("./package.json");
2 | const EleventyDevServer = require("./server.js");
3 |
4 | const Logger = {
5 | info: function(...args) {
6 | console.log( "[11ty/eleventy-dev-server]", ...args );
7 | },
8 | error: function(...args) {
9 | console.error( "[11ty/eleventy-dev-server]", ...args );
10 | },
11 | fatal: function(...args) {
12 | Logger.error(...args);
13 | process.exitCode = 1;
14 | }
15 | };
16 |
17 | Logger.log = Logger.info;
18 |
19 | class Cli {
20 | static getVersion() {
21 | return pkg.version;
22 | }
23 |
24 | static getHelp() {
25 | return `Usage:
26 |
27 | eleventy-dev-server
28 | eleventy-dev-server --dir=_site
29 | eleventy-dev-server --port=3000
30 |
31 | Arguments:
32 |
33 | --version
34 |
35 | --dir=.
36 | Directory to serve (default: \`.\`)
37 |
38 | --input (alias for --dir)
39 |
40 | --port=8080
41 | Run the web server on this port (default: \`8080\`)
42 | Will autoincrement if already in use.
43 |
44 | --domdiff (enabled, default)
45 | --domdiff=false (disabled)
46 | Apply HTML changes without a full page reload.
47 |
48 | --help`;
49 | }
50 |
51 | static getDefaultOptions() {
52 | return {
53 | port: "8080",
54 | input: ".",
55 | domDiff: true,
56 | }
57 | }
58 |
59 | async serve(options = {}) {
60 | this.options = Object.assign(Cli.getDefaultOptions(), options);
61 |
62 | this.server = EleventyDevServer.getServer("eleventy-dev-server-cli", this.options.input, {
63 | // TODO allow server configuration extensions
64 | showVersion: true,
65 | logger: Logger,
66 | domDiff: this.options.domDiff,
67 |
68 | // CLI watches all files in the folder by default
69 | // this is different from Eleventy usage!
70 | watch: [ this.options.input ],
71 | });
72 |
73 | this.server.serve(this.options.port);
74 |
75 | // TODO? send any errors here to the server too
76 | // with server.sendError({ error });
77 | }
78 |
79 | close() {
80 | if(this.server) {
81 | return this.server.close();
82 | }
83 | }
84 | }
85 |
86 | module.exports = {
87 | Logger,
88 | Cli
89 | }
90 |
--------------------------------------------------------------------------------
/client/reload-client.js:
--------------------------------------------------------------------------------
1 | class Util {
2 | static pad(num, digits = 2) {
3 | let zeroes = new Array(digits + 1).join(0);
4 | return `${zeroes}${num}`.slice(-1 * digits);
5 | }
6 |
7 | static log(message) {
8 | Util.output("log", message);
9 | }
10 | static error(message, error) {
11 | Util.output("error", message, error);
12 | }
13 | static output(type, ...messages) {
14 | let now = new Date();
15 | let date = `${Util.pad(now.getUTCHours())}:${Util.pad(
16 | now.getUTCMinutes()
17 | )}:${Util.pad(now.getUTCSeconds())}.${Util.pad(
18 | now.getUTCMilliseconds(),
19 | 3
20 | )}`;
21 | console[type](`[11ty][${date} UTC]`, ...messages);
22 | }
23 |
24 | static capitalize(word) {
25 | return word.substr(0, 1).toUpperCase() + word.substr(1);
26 | }
27 |
28 | static matchRootAttributes(htmlContent) {
29 | // Workaround for morphdom bug with attributes on https://github.com/11ty/eleventy-dev-server/issues/6
30 | // Note also `childrenOnly: true` above
31 | const parser = new DOMParser();
32 | let parsed = parser.parseFromString(htmlContent, "text/html");
33 | let parsedDoc = parsed.documentElement;
34 | let newAttrs = parsedDoc.getAttributeNames();
35 |
36 | let docEl = document.documentElement;
37 | // Remove old
38 | let removedAttrs = docEl.getAttributeNames().filter(name => !newAttrs.includes(name));
39 | for(let attr of removedAttrs) {
40 | docEl.removeAttribute(attr);
41 | }
42 |
43 | // Add new
44 | for(let attr of newAttrs) {
45 | docEl.setAttribute(attr, parsedDoc.getAttribute(attr));
46 | }
47 | }
48 |
49 | static isEleventyLinkNodeMatch(from, to) {
50 | // Issue #18 https://github.com/11ty/eleventy-dev-server/issues/18
51 | // Don’t update a if the _11ty searchParam is the only thing that’s different
52 | if(from.tagName !== "LINK" || to.tagName !== "LINK") {
53 | return false;
54 | }
55 |
56 | let oldWithoutHref = from.cloneNode();
57 | let newWithoutHref = to.cloneNode();
58 |
59 | oldWithoutHref.removeAttribute("href");
60 | newWithoutHref.removeAttribute("href");
61 |
62 | // if all other attributes besides href match
63 | if(!oldWithoutHref.isEqualNode(newWithoutHref)) {
64 | return false;
65 | }
66 |
67 | let oldUrl = new URL(from.href);
68 | let newUrl = new URL(to.href);
69 |
70 | // morphdom wants to force href="style.css?_11ty" => href="style.css"
71 | let paramName = EleventyReload.QUERY_PARAM;
72 | let isErasing = oldUrl.searchParams.has(paramName) && !newUrl.searchParams.has(paramName);
73 | if(!isErasing) {
74 | // not a match if _11ty has a new value (not being erased)
75 | return false;
76 | }
77 |
78 | oldUrl.searchParams.set(paramName, "");
79 | newUrl.searchParams.set(paramName, "");
80 |
81 | // is a match if erasing and the rest of the href matches too
82 | return oldUrl.toString() === newUrl.toString();
83 | }
84 |
85 | // https://github.com/patrick-steele-idem/morphdom/issues/178#issuecomment-652562769
86 | static runScript(source, target) {
87 | let script = document.createElement('script');
88 |
89 | // copy over the attributes
90 | for(let attr of [...source.attributes]) {
91 | script.setAttribute(attr.nodeName ,attr.nodeValue);
92 | }
93 |
94 | script.innerHTML = source.innerHTML;
95 | (target || source).replaceWith(script);
96 | }
97 |
98 | static fullPageReload() {
99 | Util.log(`Page reload initiated.`);
100 | window.location.reload();
101 | }
102 | }
103 |
104 | class EleventyReload {
105 | static QUERY_PARAM = "_11ty";
106 |
107 | static reloadTypes = {
108 | css: (files, build = {}) => {
109 | // Initiate a full page refresh if a CSS change is made but does match any stylesheet url
110 | // `build.stylesheets` available in Eleventy v3.0.1-alpha.5+
111 | if(Array.isArray(build.stylesheets)) {
112 | let match = false;
113 | for (let link of document.querySelectorAll(`link[rel="stylesheet"]`)) {
114 | if (link.href) {
115 | let url = new URL(link.href);
116 | if(build.stylesheets.includes(url.pathname)) {
117 | match = true;
118 | }
119 | }
120 | }
121 |
122 | if(!match) {
123 | Util.fullPageReload();
124 | return;
125 | }
126 | }
127 |
128 | for (let link of document.querySelectorAll(`link[rel="stylesheet"]`)) {
129 | if (link.href) {
130 | let url = new URL(link.href);
131 | url.searchParams.set(this.QUERY_PARAM, Date.now());
132 | link.href = url.toString();
133 | }
134 | }
135 |
136 | Util.log(`CSS updated without page reload.`);
137 | },
138 | default: async (files, build = {}) => {
139 | let morphed = false;
140 | let domdiffTemplates = (build?.templates || []).filter(({url, inputPath}) => {
141 | return url === document.location.pathname && (files || []).includes(inputPath);
142 | });
143 |
144 | if(domdiffTemplates.length === 0) {
145 | Util.fullPageReload();
146 | return;
147 | }
148 |
149 | try {
150 | // Important: using `./` allows the `.11ty` folder name to be changed
151 | const { default: morphdom } = await import(`./morphdom.js`);
152 |
153 | for (let {url, inputPath, content} of domdiffTemplates) {
154 | // Notable limitation: this won’t re-run script elements or JavaScript page lifecycle events (load/DOMContentLoaded)
155 | morphed = true;
156 |
157 | morphdom(document.documentElement, content, {
158 | childrenOnly: true,
159 | onBeforeElUpdated: function (fromEl, toEl) {
160 | if (fromEl.nodeName === "SCRIPT" && toEl.nodeName === "SCRIPT") {
161 | if(toEl.innerHTML !== fromEl.innerHTML) {
162 | Util.log(`JavaScript modified, reload initiated.`);
163 | window.location.reload();
164 | }
165 |
166 | return false;
167 | }
168 |
169 | // Speed-up trick from morphdom docs
170 | // https://dom.spec.whatwg.org/#concept-node-equals
171 | if (fromEl.isEqualNode(toEl)) {
172 | return false;
173 | }
174 |
175 | if(Util.isEleventyLinkNodeMatch(fromEl, toEl)) {
176 | return false;
177 | }
178 |
179 | return true;
180 | },
181 | addChild: function(parent, child) {
182 | // Declarative Shadow DOM https://github.com/11ty/eleventy-dev-server/issues/90
183 | if(child.nodeName === "TEMPLATE" && child.hasAttribute("shadowrootmode")) {
184 | let root = parent.shadowRoot;
185 | if(root) {
186 | // remove all shadow root children
187 | while(root.firstChild) {
188 | root.removeChild(root.firstChild);
189 | }
190 | }
191 | for(let newChild of child.content.childNodes) {
192 | root.appendChild(newChild);
193 | }
194 | } else {
195 | parent.appendChild(child);
196 | }
197 | },
198 | onNodeAdded: function (node) {
199 | if (node.nodeName === 'SCRIPT') {
200 | Util.log(`JavaScript added, reload initiated.`);
201 | window.location.reload();
202 | }
203 | },
204 | onElUpdated: function(node) {
205 | // Re-attach custom elements
206 | if(customElements.get(node.tagName.toLowerCase())) {
207 | let placeholder = document.createElement("div");
208 | node.replaceWith(placeholder);
209 | requestAnimationFrame(() => {
210 | placeholder.replaceWith(node);
211 | placeholder = undefined;
212 | });
213 | }
214 | }
215 | });
216 |
217 | Util.matchRootAttributes(content);
218 | Util.log(`HTML delta applied without page reload.`);
219 | }
220 | } catch(e) {
221 | Util.error( "Morphdom error", e );
222 | }
223 |
224 | if (!morphed) {
225 | Util.fullPageReload();
226 | }
227 | }
228 | }
229 |
230 | constructor() {
231 | this.connectionMessageShown = false;
232 | this.reconnectEventCallback = this.reconnect.bind(this);
233 | }
234 |
235 | init(options = {}) {
236 | if (!("WebSocket" in window)) {
237 | return;
238 | }
239 |
240 | let documentUrl = new URL(document.location.href);
241 |
242 | let reloadPort = new URL(import.meta.url).searchParams.get("reloadPort");
243 | if(reloadPort) {
244 | documentUrl.port = reloadPort;
245 | }
246 |
247 | let { protocol, host } = documentUrl;
248 |
249 | // works with http (ws) and https (wss)
250 | let websocketProtocol = protocol.replace("http", "ws");
251 |
252 | let socket = new WebSocket(`${websocketProtocol}//${host}`);
253 |
254 | socket.addEventListener("message", async (event) => {
255 | try {
256 | let data = JSON.parse(event.data);
257 | // Util.log( JSON.stringify(data, null, 2) );
258 |
259 | let { type } = data;
260 |
261 | if (type === "eleventy.reload") {
262 | await this.onreload(data);
263 | } else if (type === "eleventy.msg") {
264 | Util.log(`${data.message}`);
265 | } else if (type === "eleventy.error") {
266 | // Log Eleventy build errors
267 | // Extra parsing for Node Error objects
268 | let e = JSON.parse(data.error);
269 | Util.error(`Build error: ${e.message}`, e);
270 | } else if (type === "eleventy.status") {
271 | // Full page reload on initial reconnect
272 | if (data.status === "connected" && options.mode === "reconnect") {
273 | window.location.reload();
274 | }
275 |
276 | if(data.status === "connected") {
277 | // With multiple windows, only show one connection message
278 | if(!this.isConnected) {
279 | Util.log(Util.capitalize(data.status));
280 | }
281 |
282 | this.connectionMessageShown = true;
283 | } else {
284 | if(data.status === "disconnected") {
285 | this.addReconnectListeners();
286 | }
287 |
288 | Util.log(Util.capitalize(data.status));
289 | }
290 | } else {
291 | Util.log("Unknown event type", data);
292 | }
293 | } catch (e) {
294 | Util.error(`Error parsing ${event.data}: ${e.message}`, e);
295 | }
296 | });
297 |
298 | socket.addEventListener("open", () => {
299 | // no reconnection when the connect is already open
300 | this.removeReconnectListeners();
301 | });
302 |
303 | socket.addEventListener("close", () => {
304 | this.connectionMessageShown = false;
305 | this.addReconnectListeners();
306 | });
307 | }
308 |
309 | reconnect() {
310 | Util.log( "Reconnecting…" );
311 | this.init({ mode: "reconnect" });
312 | }
313 |
314 | async onreload({ subtype, files, build }) {
315 | if(!EleventyReload.reloadTypes[subtype]) {
316 | subtype = "default";
317 | }
318 |
319 | await EleventyReload.reloadTypes[subtype](files, build);
320 | }
321 |
322 | addReconnectListeners() {
323 | this.removeReconnectListeners();
324 |
325 | window.addEventListener("focus", this.reconnectEventCallback);
326 | window.addEventListener("visibilitychange", this.reconnectEventCallback);
327 | }
328 |
329 | removeReconnectListeners() {
330 | window.removeEventListener("focus", this.reconnectEventCallback);
331 | window.removeEventListener("visibilitychange", this.reconnectEventCallback);
332 | }
333 | }
334 |
335 | let reloader = new EleventyReload();
336 | reloader.init();
--------------------------------------------------------------------------------
/cmd.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const pkg = require("./package.json");
4 |
5 | // Node check
6 | require("please-upgrade-node")(pkg, {
7 | message: function (requiredVersion) {
8 | return (
9 | "eleventy-dev-server requires Node " +
10 | requiredVersion +
11 | ". You will need to upgrade Node!"
12 | );
13 | },
14 | });
15 |
16 | const { Logger, Cli } = require("./cli.js");
17 |
18 | const debug = require("debug")("Eleventy:DevServer");
19 |
20 | try {
21 | const defaults = Cli.getDefaultOptions();
22 | for(let key in defaults) {
23 | if(key.toLowerCase() !== key) {
24 | defaults[key.toLowerCase()] = defaults[key];
25 | delete defaults[key];
26 | }
27 | }
28 |
29 | const argv = require("minimist")(process.argv.slice(2), {
30 | string: [
31 | "dir",
32 | "input", // alias for dir
33 | "port",
34 | ],
35 | boolean: [
36 | "version",
37 | "help",
38 | "domdiff",
39 | ],
40 | default: defaults,
41 | unknown: function (unknownArgument) {
42 | throw new Error(
43 | `We don’t know what '${unknownArgument}' is. Use --help to see the list of supported commands.`
44 | );
45 | },
46 | });
47 |
48 | debug("command: eleventy-dev-server %o", argv);
49 |
50 | process.on("unhandledRejection", (error, promise) => {
51 | Logger.fatal("Unhandled rejection in promise:", promise, error);
52 | });
53 | process.on("uncaughtException", (error) => {
54 | Logger.fatal("Uncaught exception:", error);
55 | });
56 |
57 | if (argv.version) {
58 | console.log(Cli.getVersion());
59 | } else if (argv.help) {
60 | console.log(Cli.getHelp());
61 | } else {
62 | let cli = new Cli();
63 |
64 | cli.serve({
65 | input: argv.dir || argv.input,
66 | port: argv.port,
67 | domDiff: argv.domdiff,
68 | });
69 |
70 | process.on("SIGINT", async () => {
71 | await cli.close();
72 | process.exitCode = 0;
73 | });
74 | }
75 | } catch (e) {
76 | Logger.fatal("Fatal Error:", e)
77 | }
78 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@11ty/eleventy-dev-server",
3 | "version": "2.0.8",
4 | "description": "A minimal, modern, generic, hot-reloading local web server to help web developers.",
5 | "main": "server.js",
6 | "scripts": {
7 | "test": "npx ava --verbose",
8 | "sample": "node cmd.js --input=test/stubs"
9 | },
10 | "license": "MIT",
11 | "engines": {
12 | "node": ">=18"
13 | },
14 | "funding": {
15 | "type": "opencollective",
16 | "url": "https://opencollective.com/11ty"
17 | },
18 | "bin": {
19 | "eleventy-dev-server": "./cmd.js"
20 | },
21 | "keywords": [
22 | "eleventy",
23 | "server",
24 | "cli"
25 | ],
26 | "publishConfig": {
27 | "access": "public"
28 | },
29 | "author": {
30 | "name": "Zach Leatherman",
31 | "email": "zachleatherman@gmail.com",
32 | "url": "https://zachleat.com/"
33 | },
34 | "repository": {
35 | "type": "git",
36 | "url": "git://github.com/11ty/eleventy-dev-server.git"
37 | },
38 | "bugs": "https://github.com/11ty/eleventy-dev-server/issues",
39 | "homepage": "https://github.com/11ty/eleventy-dev-server/",
40 | "dependencies": {
41 | "@11ty/eleventy-utils": "^2.0.1",
42 | "chokidar": "^3.6.0",
43 | "debug": "^4.4.0",
44 | "finalhandler": "^1.3.1",
45 | "mime": "^3.0.0",
46 | "minimist": "^1.2.8",
47 | "morphdom": "^2.7.4",
48 | "please-upgrade-node": "^3.2.0",
49 | "send": "^1.1.0",
50 | "ssri": "^11.0.0",
51 | "urlpattern-polyfill": "^10.0.0",
52 | "ws": "^8.18.1"
53 | },
54 | "devDependencies": {
55 | "ava": "^6.2.0"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | const path = require("node:path");
2 | const fs = require("node:fs");
3 |
4 | const finalhandler = require("finalhandler");
5 | const WebSocket = require("ws");
6 | const { WebSocketServer } = WebSocket;
7 | const mime = require("mime");
8 | const ssri = require("ssri");
9 | const send = require("send");
10 | const chokidar = require("chokidar");
11 | const { TemplatePath, isPlainObject } = require("@11ty/eleventy-utils");
12 |
13 | const debug = require("debug")("Eleventy:DevServer");
14 |
15 | const pkg = require("./package.json");
16 | const wrapResponse = require("./server/wrapResponse.js");
17 | const ipAddress = require("./server/ipAddress.js");
18 |
19 | if (!globalThis.URLPattern) {
20 | require("urlpattern-polyfill");
21 | }
22 |
23 | const DEFAULT_OPTIONS = {
24 | port: 8080,
25 | reloadPort: false, // Falsy uses same as `port`
26 | liveReload: true, // Enable live reload at all
27 | showAllHosts: false, // IP address based hosts (other than localhost)
28 | injectedScriptsFolder: ".11ty", // Change the name of the special folder used for injected scripts
29 | portReassignmentRetryCount: 10, // number of times to increment the port if in use
30 | https: {}, // `key` and `cert`, required for http/2 and https
31 | domDiff: true, // Use morphdom to apply DOM diffing delta updates to HTML
32 | showVersion: false, // Whether or not to show the server version on the command line.
33 | encoding: "utf-8", // Default file encoding
34 | pathPrefix: "/", // May be overridden by Eleventy, adds a virtual base directory to your project
35 | watch: [], // Globs to pass to separate dev server chokidar for watching
36 | aliases: {}, // Aliasing feature
37 | indexFileName: "index.html", // Allow custom index file name
38 | useCache: false, // Use a cache for file contents
39 | headers: {}, // Set default response headers
40 | messageOnStart: ({ hosts, startupTime, version, options }) => {
41 | let hostsStr = " started";
42 | if(Array.isArray(hosts) && hosts.length > 0) {
43 | // TODO what happens when the cert doesn’t cover non-localhost hosts?
44 | hostsStr = ` at ${hosts.join(" or ")}`;
45 | }
46 |
47 | return `Server${hostsStr}${options.showVersion ? ` (v${version})` : ""}`;
48 | },
49 |
50 | onRequest: {}, // Maps URLPatterns to dynamic callback functions that run on a request from a client.
51 |
52 | // Example:
53 | // "/foo/:name": function({ url, pattern, patternGroups }) {
54 | // return {
55 | // headers: {
56 | // "Content-Type": "text/html",
57 | // },
58 | // body: `${url} ${JSON.stringify(patternGroups)}`
59 | // }
60 | // }
61 |
62 | // Logger (fancier one is injected by Eleventy)
63 | logger: {
64 | info: console.log,
65 | log: console.log,
66 | error: console.error,
67 | }
68 | }
69 |
70 | class EleventyDevServer {
71 | #watcher;
72 | #serverClosing;
73 | #serverState;
74 | #readyPromise;
75 | #readyResolve;
76 |
77 | static getServer(...args) {
78 | return new EleventyDevServer(...args);
79 | }
80 |
81 | constructor(name, dir, options = {}) {
82 | debug("Creating new Dev Server instance.")
83 | this.name = name;
84 | this.normalizeOptions(options);
85 |
86 | this.fileCache = {};
87 | // Directory to serve
88 | if(!dir) {
89 | throw new Error("Missing `dir` to serve.");
90 | }
91 | this.dir = dir;
92 | this.logger = this.options.logger;
93 | this.getWatcher();
94 |
95 | this.#readyPromise = new Promise((resolve) => {
96 | this.#readyResolve = resolve;
97 | })
98 | }
99 |
100 | normalizeOptions(options = {}) {
101 | this.options = Object.assign({}, DEFAULT_OPTIONS, options);
102 |
103 | // better names for options https://github.com/11ty/eleventy-dev-server/issues/41
104 | if(options.folder !== undefined) {
105 | this.options.injectedScriptsFolder = options.folder;
106 | delete this.options.folder;
107 | }
108 | if(options.domdiff !== undefined) {
109 | this.options.domDiff = options.domdiff;
110 | delete this.options.domdiff;
111 | }
112 | if(options.enabled !== undefined) {
113 | this.options.liveReload = options.enabled;
114 | delete this.options.enabled;
115 | }
116 |
117 | this.options.pathPrefix = this.cleanupPathPrefix(this.options.pathPrefix);
118 | }
119 |
120 | get watcher() {
121 | if(this.#watcher) {
122 | return this.#watcher;
123 | }
124 |
125 | debug("Watching %O", this.options.watch);
126 | // TODO if using Eleventy and `watch` option includes output folder (_site) this will trigger two update events!
127 | this.#watcher = chokidar.watch(this.options.watch, {
128 | // TODO allow chokidar configuration extensions (or re-use the ones in Eleventy)
129 |
130 | ignored: ["**/node_modules/**", ".git"],
131 | ignoreInitial: true,
132 |
133 | // same values as Eleventy
134 | awaitWriteFinish: {
135 | stabilityThreshold: 150,
136 | pollInterval: 25,
137 | },
138 | });
139 |
140 | this.#watcher.on("change", (path) => {
141 | this.logger.log( `File changed: ${path} (skips build)` );
142 | this.reloadFiles([path]);
143 | });
144 |
145 | this.#watcher.on("add", (path) => {
146 | this.logger.log( `File added: ${path} (skips build)` );
147 | this.reloadFiles([path]);
148 | });
149 |
150 | return this.#watcher;
151 | }
152 |
153 | getWatcher() {
154 | // only initialize watcher if watcher via getWatcher if has targets
155 | // this.watcher in watchFiles() is a manual workaround
156 | if(this.options.watch.length > 0) {
157 | return this.watcher;
158 | }
159 | }
160 |
161 | watchFiles(files) {
162 | if(Array.isArray(files) && files.length > 0) {
163 | files = files.map(entry => TemplatePath.stripLeadingDotSlash(entry));
164 |
165 | debug("Also watching %O", files);
166 | this.watcher.add(files);
167 | }
168 | }
169 |
170 | cleanupPathPrefix(pathPrefix) {
171 | if(!pathPrefix || pathPrefix === "/") {
172 | return "/";
173 | }
174 | if(!pathPrefix.startsWith("/")) {
175 | pathPrefix = `/${pathPrefix}`
176 | }
177 | if(!pathPrefix.endsWith("/")) {
178 | pathPrefix = `${pathPrefix}/`;
179 | }
180 | return pathPrefix;
181 | }
182 |
183 | // Allowed list of files that can be served from outside `dir`
184 | setAliases(aliases) {
185 | if(aliases) {
186 | this.passthroughAliases = aliases;
187 | debug( "Setting aliases (emulated passthrough copy) %O", aliases );
188 | }
189 | }
190 |
191 | matchPassthroughAlias(url) {
192 | let aliases = Object.assign({}, this.options.aliases, this.passthroughAliases);
193 | for(let targetUrl in aliases) {
194 | if(!targetUrl) {
195 | continue;
196 | }
197 |
198 | let file = aliases[targetUrl];
199 | if(url.startsWith(targetUrl)) {
200 | let inputDirectoryPath = file + url.slice(targetUrl.length);
201 |
202 | // e.g. addPassthroughCopy("img/") but
203 | // generated by the image plugin (written to the output folder)
204 | // If they do not exist in the input directory, this will fallback to the output directory.
205 | if(fs.existsSync(inputDirectoryPath)) {
206 | return inputDirectoryPath;
207 | }
208 | }
209 | }
210 | return false;
211 | }
212 |
213 | isFileInDirectory(dir, file) {
214 | let absoluteDir = TemplatePath.absolutePath(dir);
215 | let absoluteFile = TemplatePath.absolutePath(file);
216 | return absoluteFile.startsWith(absoluteDir);
217 | }
218 |
219 | getOutputDirFilePath(filepath, filename = "") {
220 | let computedPath;
221 | if(filename === ".html") {
222 | // avoid trailing slash for filepath/.html requests
223 | let prefix = path.join(this.dir, filepath);
224 | if(prefix.endsWith(path.sep)) {
225 | prefix = prefix.substring(0, prefix.length - path.sep.length);
226 | }
227 | computedPath = prefix + filename;
228 | } else {
229 | computedPath = path.join(this.dir, filepath, filename);
230 | }
231 |
232 | computedPath = decodeURIComponent(computedPath);
233 |
234 | if(!filename) { // is a direct URL request (not an implicit .html or index.html add)
235 | let alias = this.matchPassthroughAlias(filepath);
236 |
237 | if(alias) {
238 | if(!this.isFileInDirectory(path.resolve("."), alias)) {
239 | throw new Error("Invalid path");
240 | }
241 |
242 | return alias;
243 | }
244 | }
245 |
246 | // Check that the file is in the output path (error if folks try use `..` in the filepath)
247 | if(!this.isFileInDirectory(this.dir, computedPath)) {
248 | throw new Error("Invalid path");
249 | }
250 |
251 | return computedPath;
252 | }
253 |
254 | isOutputFilePathExists(rawPath) {
255 | return fs.existsSync(rawPath) && !TemplatePath.isDirectorySync(rawPath);
256 | }
257 |
258 | /* Use conventions documented here https://www.zachleat.com/web/trailing-slash/
259 | * resource.html exists:
260 | * /resource matches
261 | * /resource/ redirects to /resource
262 | * resource/index.html exists:
263 | * /resource redirects to /resource/
264 | * /resource/ matches
265 | * both resource.html and resource/index.html exists:
266 | * /resource matches /resource.html
267 | * /resource/ matches /resource/index.html
268 | */
269 | mapUrlToFilePath(url) {
270 | // Note: `localhost` is not important here, any host would work
271 | let u = new URL(url, "http://localhost/");
272 | url = u.pathname;
273 |
274 | // Remove PathPrefix from start of URL
275 | if (this.options.pathPrefix !== "/") {
276 | // Requests to root should redirect to new pathPrefix
277 | if(url === "/") {
278 | return {
279 | statusCode: 302,
280 | url: this.options.pathPrefix,
281 | }
282 | }
283 |
284 | // Requests to anything outside of root should fail with 404
285 | if (!url.startsWith(this.options.pathPrefix)) {
286 | return {
287 | statusCode: 404,
288 | };
289 | }
290 |
291 | url = url.slice(this.options.pathPrefix.length - 1);
292 | }
293 |
294 | let rawPath = this.getOutputDirFilePath(url);
295 | if (this.isOutputFilePathExists(rawPath)) {
296 | return {
297 | statusCode: 200,
298 | filepath: rawPath,
299 | };
300 | }
301 |
302 | let indexHtmlPath = this.getOutputDirFilePath(url, this.options.indexFileName);
303 | let indexHtmlExists = fs.existsSync(indexHtmlPath);
304 |
305 | let htmlPath = this.getOutputDirFilePath(url, ".html");
306 | let htmlExists = fs.existsSync(htmlPath);
307 |
308 | // /resource/ => /resource/index.html
309 | if (indexHtmlExists && url.endsWith("/")) {
310 | return {
311 | statusCode: 200,
312 | filepath: indexHtmlPath,
313 | };
314 | }
315 | // /resource => resource.html
316 | if (htmlExists && !url.endsWith("/")) {
317 | return {
318 | statusCode: 200,
319 | filepath: htmlPath,
320 | };
321 | }
322 |
323 | // /resource => redirect to /resource/
324 | if (indexHtmlExists && !url.endsWith("/")) {
325 | return {
326 | statusCode: 301,
327 | url: u.pathname + "/",
328 | };
329 | }
330 |
331 | // /resource/ => redirect to /resource
332 | if (htmlExists && url.endsWith("/")) {
333 | return {
334 | statusCode: 301,
335 | url: u.pathname.substring(0, u.pathname.length - 1),
336 | };
337 | }
338 |
339 | return {
340 | statusCode: 404,
341 | };
342 | }
343 |
344 | #readFile(filepath) {
345 | if(this.options.useCache && this.fileCache[filepath]) {
346 | return this.fileCache[filepath];
347 | }
348 |
349 | let contents = fs.readFileSync(filepath, {
350 | encoding: this.options.encoding,
351 | });
352 |
353 | if(this.options.useCache) {
354 | this.fileCache[filepath] = contents;
355 | }
356 |
357 | return contents;
358 | }
359 |
360 | #getFileContents(localpath, rootDir) {
361 | let filepath;
362 | let searchLocations = [];
363 |
364 | if(rootDir) {
365 | searchLocations.push(TemplatePath.absolutePath(rootDir, localpath));
366 | }
367 |
368 | // fallbacks for file:../ installations
369 | searchLocations.push(TemplatePath.absolutePath(__dirname, localpath));
370 | searchLocations.push(TemplatePath.absolutePath(__dirname, "../../../", localpath));
371 |
372 | for(let loc of searchLocations) {
373 | if(fs.existsSync(loc)) {
374 | filepath = loc;
375 | break;
376 | }
377 | }
378 |
379 | return this.#readFile(filepath);
380 | }
381 |
382 | augmentContentWithNotifier(content, inlineContents = false, options = {}) {
383 | let { integrityHash, scriptContents } = options;
384 | if(!scriptContents) {
385 | scriptContents = this.#getFileContents("./client/reload-client.js");
386 | }
387 | if(!integrityHash) {
388 | integrityHash = ssri.fromData(scriptContents);
389 | }
390 |
391 | let searchParams = new URLSearchParams();
392 | if(this.options.reloadPort) {
393 | searchParams.set("reloadPort", this.options.reloadPort);
394 | }
395 |
396 | let searchParamsStr = searchParams.size > 0 ? `?${searchParams.toString()}` : "";
397 |
398 | // This isn’t super necessary because it’s a local file, but it’s included anyway
399 | let script = ``;
400 |
401 | if (content.includes("")) {
402 | return content.replace("", `${script}`);
403 | }
404 |
405 | // If the HTML document contains an importmap, insert the module script after the importmap element
406 | let importMapRegEx = /
15 |