├── .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 |

11ty Logo

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 | [![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) 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 | 16 | -------------------------------------------------------------------------------- /test/stubs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | test/stubs/index.html 11 | 12 | -------------------------------------------------------------------------------- /test/stubs/index.php: -------------------------------------------------------------------------------- 1 | SAMPLE PHP 2 | -------------------------------------------------------------------------------- /test/stubs/petite-vue.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
test ljskdlfjlkdsjflk
11 |
test ljskdlfjlkdsjflk
12 |
test ljskdlfjlkdsjflk
13 | 14 | -------------------------------------------------------------------------------- /test/stubs/route space.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-dev-server/1c658605f75224fdc76f68aebe7a412eeb4f1bc9/test/stubs/route space.html -------------------------------------------------------------------------------- /test/stubs/route1/custom-index.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-dev-server/1c658605f75224fdc76f68aebe7a412eeb4f1bc9/test/stubs/route1/custom-index.html -------------------------------------------------------------------------------- /test/stubs/route1/index.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-dev-server/1c658605f75224fdc76f68aebe7a412eeb4f1bc9/test/stubs/route1/index.html -------------------------------------------------------------------------------- /test/stubs/route2.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-dev-server/1c658605f75224fdc76f68aebe7a412eeb4f1bc9/test/stubs/route2.html -------------------------------------------------------------------------------- /test/stubs/route3.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-dev-server/1c658605f75224fdc76f68aebe7a412eeb4f1bc9/test/stubs/route3.html -------------------------------------------------------------------------------- /test/stubs/route3/index.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/11ty/eleventy-dev-server/1c658605f75224fdc76f68aebe7a412eeb4f1bc9/test/stubs/route3/index.html -------------------------------------------------------------------------------- /test/stubs/sample.html: -------------------------------------------------------------------------------- 1 | SAMPLE -------------------------------------------------------------------------------- /test/stubs/with-css/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | BODY TEXT 12 | 13 | -------------------------------------------------------------------------------- /test/stubs/with-css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: blue; 3 | } -------------------------------------------------------------------------------- /test/stubs/with-import-map.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 16 | 17 | 18 | This page has an importmap 19 | 20 | -------------------------------------------------------------------------------- /test/stubs/zach’s.html: -------------------------------------------------------------------------------- 1 | This is a test -------------------------------------------------------------------------------- /test/testServer.js: -------------------------------------------------------------------------------- 1 | const test = require("ava"); 2 | const path = require("path"); 3 | const EleventyDevServer = require("../"); 4 | 5 | function testNormalizeFilePath(filepath) { 6 | return filepath.split("/").join(path.sep); 7 | } 8 | 9 | test("Url mappings for resource/index.html", async (t) => { 10 | let server = new EleventyDevServer("test-server", "./test/stubs/"); 11 | 12 | t.deepEqual(server.mapUrlToFilePath("/route1/"), { 13 | statusCode: 200, 14 | filepath: testNormalizeFilePath("test/stubs/route1/index.html") 15 | }); 16 | 17 | t.deepEqual(server.mapUrlToFilePath("/route1"), { 18 | statusCode: 301, 19 | url: "/route1/" 20 | }); 21 | 22 | t.deepEqual(server.mapUrlToFilePath("/route1.html"), { 23 | statusCode: 404 24 | }); 25 | 26 | t.deepEqual(server.mapUrlToFilePath("/route1/index.html"), { 27 | statusCode: 200, 28 | filepath: testNormalizeFilePath("test/stubs/route1/index.html") 29 | }); 30 | 31 | await server.close(); 32 | }); 33 | 34 | test("Url mappings for resource.html", async (t) => { 35 | let server = new EleventyDevServer("test-server", "./test/stubs/"); 36 | 37 | t.deepEqual(server.mapUrlToFilePath("/route2/"), { 38 | statusCode: 301, 39 | url: "/route2" 40 | }); 41 | 42 | t.deepEqual(server.mapUrlToFilePath("/route2/index.html"), { 43 | statusCode: 404 44 | }); 45 | 46 | t.deepEqual(server.mapUrlToFilePath("/route2"), { 47 | statusCode: 200, 48 | filepath: testNormalizeFilePath("test/stubs/route2.html") 49 | }); 50 | 51 | t.deepEqual(server.mapUrlToFilePath("/route2.html"), { 52 | statusCode: 200, 53 | filepath: testNormalizeFilePath("test/stubs/route2.html",) 54 | }); 55 | 56 | await server.close(); 57 | }); 58 | 59 | test("Url mappings for resource.html and resource/index.html", async (t) => { 60 | let server = new EleventyDevServer("test-server", "./test/stubs/"); 61 | 62 | // Production mismatch warning: Netlify 301 redirects to /route3 here 63 | t.deepEqual(server.mapUrlToFilePath("/route3/"), { 64 | statusCode: 200, 65 | filepath: testNormalizeFilePath("test/stubs/route3/index.html",) 66 | }); 67 | 68 | t.deepEqual(server.mapUrlToFilePath("/route3/index.html"), { 69 | statusCode: 200, 70 | filepath: testNormalizeFilePath("test/stubs/route3/index.html",) 71 | }); 72 | 73 | t.deepEqual(server.mapUrlToFilePath("/route3"), { 74 | statusCode: 200, 75 | filepath: testNormalizeFilePath("test/stubs/route3.html") 76 | }); 77 | 78 | t.deepEqual(server.mapUrlToFilePath("/route3.html"), { 79 | statusCode: 200, 80 | filepath: testNormalizeFilePath("test/stubs/route3.html",) 81 | }); 82 | 83 | await server.close(); 84 | }); 85 | 86 | test("Url mappings for missing resource", async (t) => { 87 | let server = new EleventyDevServer("test-server", "./test/stubs/"); 88 | 89 | // 404s 90 | t.deepEqual(server.mapUrlToFilePath("/does-not-exist/"), { 91 | statusCode: 404 92 | }); 93 | 94 | await server.close(); 95 | }); 96 | 97 | test("Url mapping for a filename with a space in it", async (t) => { 98 | let server = new EleventyDevServer("test-server", "./test/stubs/"); 99 | 100 | t.deepEqual(server.mapUrlToFilePath("/route space.html"), { 101 | statusCode: 200, 102 | filepath: testNormalizeFilePath("test/stubs/route space.html",) 103 | }); 104 | 105 | await server.close(); 106 | }); 107 | 108 | test("matchPassthroughAlias", async (t) => { 109 | let server = new EleventyDevServer("test-server", "./test/stubs/"); 110 | 111 | // url => project root input 112 | server.setAliases({ 113 | // works with directories 114 | "/img": "./test/stubs/img", 115 | "/elsewhere": "./test/stubs/alternative", 116 | // or full paths 117 | "/elsewhere/index.css": "./test/stubs/with-css/style.css", 118 | }); 119 | 120 | // No map entry 121 | t.is(server.matchPassthroughAlias("/"), false); 122 | t.is(server.matchPassthroughAlias("/index.html"), false); // file exists 123 | 124 | // File exists 125 | t.is(server.matchPassthroughAlias("/elsewhere"), "./test/stubs/alternative"); 126 | t.is(server.matchPassthroughAlias("/elsewhere/test"), "./test/stubs/alternative/test"); 127 | 128 | // Map entry exists but file does not exist 129 | t.is(server.matchPassthroughAlias("/elsewhere/test.png"), false); 130 | t.is(server.matchPassthroughAlias("/elsewhere/another.css"), false); 131 | 132 | // Map entry exists, file exists 133 | t.is(server.matchPassthroughAlias("/elsewhere/index.css"), "./test/stubs/with-css/style.css"); 134 | 135 | await server.close(); 136 | }); 137 | 138 | 139 | test("pathPrefix matching", async (t) => { 140 | let server = new EleventyDevServer("test-server", "./test/stubs/", { 141 | pathPrefix: "/pathprefix/" 142 | }); 143 | 144 | t.deepEqual(server.mapUrlToFilePath("/pathprefix/route1/"), { 145 | statusCode: 200, 146 | filepath: testNormalizeFilePath("test/stubs/route1/index.html") 147 | }); 148 | 149 | t.deepEqual(server.mapUrlToFilePath("/pathprefix/"), { 150 | statusCode: 200, 151 | filepath: testNormalizeFilePath("test/stubs/index.html") 152 | }); 153 | 154 | // `/` should redirect to pathprefix 155 | t.deepEqual(server.mapUrlToFilePath("/"), { 156 | statusCode: 302, 157 | url: '/pathprefix/', 158 | }); 159 | 160 | await server.close(); 161 | }); 162 | 163 | test("pathPrefix without leading slash", async (t) => { 164 | let server = new EleventyDevServer("test-server", "./test/stubs/", { 165 | pathPrefix: "pathprefix/" 166 | }); 167 | 168 | t.deepEqual(server.mapUrlToFilePath("/pathprefix/route1/"), { 169 | statusCode: 200, 170 | filepath: testNormalizeFilePath("test/stubs/route1/index.html") 171 | }); 172 | 173 | t.deepEqual(server.mapUrlToFilePath("/pathprefix/"), { 174 | statusCode: 200, 175 | filepath: testNormalizeFilePath("test/stubs/index.html") 176 | }); 177 | 178 | // `/` should redirect to pathprefix 179 | t.deepEqual(server.mapUrlToFilePath("/"), { 180 | statusCode: 302, 181 | url: '/pathprefix/', 182 | }); 183 | 184 | await server.close(); 185 | }); 186 | 187 | test("pathPrefix without trailing slash", async (t) => { 188 | let server = new EleventyDevServer("test-server", "./test/stubs/", { 189 | pathPrefix: "/pathprefix" 190 | }); 191 | 192 | t.deepEqual(server.mapUrlToFilePath("/pathprefix/route1/"), { 193 | statusCode: 200, 194 | filepath: testNormalizeFilePath("test/stubs/route1/index.html") 195 | }); 196 | 197 | t.deepEqual(server.mapUrlToFilePath("/pathprefix/"), { 198 | statusCode: 200, 199 | filepath: testNormalizeFilePath("test/stubs/index.html") 200 | }); 201 | 202 | // `/` should redirect to pathprefix 203 | t.deepEqual(server.mapUrlToFilePath("/"), { 204 | statusCode: 302, 205 | url: '/pathprefix/', 206 | }); 207 | 208 | await server.close(); 209 | }); 210 | 211 | test("pathPrefix without leading or trailing slash", async (t) => { 212 | let server = new EleventyDevServer("test-server", "./test/stubs/", { 213 | pathPrefix: "pathprefix" 214 | }); 215 | 216 | t.deepEqual(server.mapUrlToFilePath("/pathprefix/route1/"), { 217 | statusCode: 200, 218 | filepath: testNormalizeFilePath("test/stubs/route1/index.html") 219 | }); 220 | 221 | t.deepEqual(server.mapUrlToFilePath("/pathprefix/"), { 222 | statusCode: 200, 223 | filepath: testNormalizeFilePath("test/stubs/index.html") 224 | }); 225 | 226 | // `/` should redirect to pathprefix 227 | t.deepEqual(server.mapUrlToFilePath("/"), { 228 | statusCode: 302, 229 | url: '/pathprefix/', 230 | }); 231 | 232 | await server.close(); 233 | }); 234 | 235 | test("indexFileName option: serve custom index when provided", async (t) => { 236 | let server = new EleventyDevServer("test-server", "./test/stubs/", { indexFileName: 'custom-index.html' }); 237 | 238 | t.deepEqual(server.mapUrlToFilePath("/"), { 239 | statusCode: 200, 240 | filepath: testNormalizeFilePath("test/stubs/custom-index.html"), 241 | }); 242 | 243 | 244 | t.deepEqual(server.mapUrlToFilePath("/route1/"), { 245 | statusCode: 200, 246 | filepath: testNormalizeFilePath("test/stubs/route1/custom-index.html"), 247 | }); 248 | 249 | await server.close(); 250 | }); 251 | 252 | test("indexFileName option: return 404 when custom index file doesn't exist", async (t) => { 253 | let server = new EleventyDevServer("test-server", "./test/stubs/", { indexFileName: 'does-not-exist.html' }); 254 | 255 | t.deepEqual(server.mapUrlToFilePath("/"), { 256 | statusCode: 404, 257 | }); 258 | 259 | await server.close(); 260 | }); 261 | 262 | test("Test watch getter", async (t) => { 263 | let server = new EleventyDevServer("test-server", "./test/stubs/"); 264 | 265 | t.truthy(server.watcher); 266 | 267 | await server.close(); 268 | }); 269 | -------------------------------------------------------------------------------- /test/testServerRequests.js: -------------------------------------------------------------------------------- 1 | const test = require("ava"); 2 | const path = require("path"); 3 | const http = require("http"); 4 | const EleventyDevServer = require("../"); 5 | 6 | function getOptions(options = {}) { 7 | options.logger = { 8 | info: function() {}, 9 | error: function() {}, 10 | }; 11 | options.portReassignmentRetryCount = 100; 12 | return options; 13 | } 14 | 15 | async function makeRequestTo(t, server, path) { 16 | let port = await server.getPort(); 17 | 18 | return new Promise(resolve => { 19 | const options = { 20 | hostname: 'localhost', 21 | port, 22 | path, 23 | method: 'GET', 24 | }; 25 | 26 | http.get(options, (res) => { 27 | const { statusCode } = res; 28 | if(statusCode !== 200) { 29 | throw new Error("Invalid status code" + statusCode); 30 | } 31 | 32 | res.setEncoding('utf8'); 33 | 34 | let rawData = ''; 35 | res.on('data', (chunk) => { rawData += chunk; }); 36 | res.on('end', () => { 37 | t.true( true ); 38 | resolve(rawData); 39 | }); 40 | }).on('error', (e) => { 41 | console.error(`Got error: ${e.message}`); 42 | }); 43 | }) 44 | } 45 | 46 | async function fetchHeadersForRequest(t, server, path, extras) { 47 | let port = await server.getPort(); 48 | 49 | return new Promise(resolve => { 50 | const options = { 51 | hostname: 'localhost', 52 | port, 53 | path, 54 | method: 'GET', 55 | ...extras, 56 | }; 57 | 58 | // Available status codes can be found here: http.STATUS_CODES 59 | const successCodes = [ 60 | 200, // OK 61 | 206, // Partial Content 62 | ]; 63 | http.get(options, (res) => { 64 | const { statusCode } = res; 65 | if (!successCodes.includes(statusCode)) { 66 | throw new Error("Invalid status code " + statusCode); 67 | } 68 | 69 | let headers = res.headers; 70 | resolve(headers); 71 | 72 | }).on('error', (e) => { 73 | console.error(`Got error: ${e.message}`); 74 | }); 75 | }) 76 | } 77 | 78 | test("Standard request", async (t) => { 79 | let server = new EleventyDevServer("test-server", "./test/stubs/", getOptions()); 80 | server.serve(8100); 81 | 82 | let data = await makeRequestTo(t, server, "/sample"); 83 | t.true(data.includes("