inital
This commit is contained in:
60
node_modules/@11ty/eleventy-dev-server/README.md
generated
vendored
Normal file
60
node_modules/@11ty/eleventy-dev-server/README.md
generated
vendored
Normal file
@@ -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)
|
||||
|
||||
[](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.
|
89
node_modules/@11ty/eleventy-dev-server/cli.js
generated
vendored
Normal file
89
node_modules/@11ty/eleventy-dev-server/cli.js
generated
vendored
Normal file
@@ -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
|
||||
}
|
294
node_modules/@11ty/eleventy-dev-server/client/reload-client.js
generated
vendored
Normal file
294
node_modules/@11ty/eleventy-dev-server/client/reload-client.js
generated
vendored
Normal file
@@ -0,0 +1,294 @@
|
||||
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 isErasing = oldUrl.searchParams.has("_11ty") && !newUrl.searchParams.has("_11ty");
|
||||
if(!isErasing) {
|
||||
// not a match if _11ty has a new value (not being erased)
|
||||
return false;
|
||||
}
|
||||
|
||||
oldUrl.searchParams.set("_11ty", "");
|
||||
newUrl.searchParams.set("_11ty", "");
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
class EleventyReload {
|
||||
constructor() {
|
||||
this.connectionMessageShown = false;
|
||||
this.reconnectEventCallback = this.reconnect.bind(this);
|
||||
}
|
||||
|
||||
init(options = {}) {
|
||||
if (!("WebSocket" in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let { protocol, host } = new URL(document.location.href);
|
||||
|
||||
// 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 (subtype === "css") {
|
||||
for (let link of document.querySelectorAll(`link[rel="stylesheet"]`)) {
|
||||
if (link.href) {
|
||||
let url = new URL(link.href);
|
||||
url.searchParams.set("_11ty", Date.now());
|
||||
link.href = url.toString();
|
||||
}
|
||||
}
|
||||
Util.log(`CSS updated without page reload.`);
|
||||
} else {
|
||||
let morphed = false;
|
||||
|
||||
try {
|
||||
if((build.templates || []).length > 0) {
|
||||
// Important: using `./` in `./morphdom.js` allows the special `.11ty` folder to be changed upstream
|
||||
const { default: morphdom } = await import(`./morphdom.js`);
|
||||
|
||||
// { url, inputPath, content }
|
||||
for (let template of build.templates || []) {
|
||||
if (template.url === document.location.pathname) {
|
||||
// Importantly, if this does not match but is still relevant (layout/include/etc), a full reload happens below. This could be improved.
|
||||
if ((files || []).includes(template.inputPath)) {
|
||||
// Notable limitation: this won’t re-run script elements or JavaScript page lifecycle events (load/DOMContentLoaded)
|
||||
morphed = true;
|
||||
|
||||
morphdom(document.documentElement, template.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(template.content);
|
||||
Util.log(`HTML delta applied without page reload.`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
Util.error( "Morphdom error", e );
|
||||
}
|
||||
|
||||
if (!morphed) {
|
||||
Util.log(`Page reload initiated.`);
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
77
node_modules/@11ty/eleventy-dev-server/cmd.js
generated
vendored
Executable file
77
node_modules/@11ty/eleventy-dev-server/cmd.js
generated
vendored
Executable file
@@ -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)
|
||||
}
|
58
node_modules/@11ty/eleventy-dev-server/package.json
generated
vendored
Normal file
58
node_modules/@11ty/eleventy-dev-server/package.json
generated
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@11ty/eleventy-dev-server",
|
||||
"version": "2.0.4",
|
||||
"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": "^1.0.3",
|
||||
"chokidar": "^3.6.0",
|
||||
"debug": "^4.3.7",
|
||||
"dev-ip": "^1.0.1",
|
||||
"finalhandler": "^1.3.0",
|
||||
"mime": "^3.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"morphdom": "^2.7.4",
|
||||
"please-upgrade-node": "^3.2.0",
|
||||
"send": "^0.19.0",
|
||||
"ssri": "^11.0.0",
|
||||
"urlpattern-polyfill": "^10.0.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ava": "^6.1.3"
|
||||
}
|
||||
}
|
950
node_modules/@11ty/eleventy-dev-server/server.js
generated
vendored
Normal file
950
node_modules/@11ty/eleventy-dev-server/server.js
generated
vendored
Normal file
@@ -0,0 +1,950 @@
|
||||
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 devip = require("dev-ip");
|
||||
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");
|
||||
|
||||
if (!globalThis.URLPattern) {
|
||||
require("urlpattern-polyfill");
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
port: 8080,
|
||||
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 {
|
||||
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;
|
||||
|
||||
if(this.options.watch.length > 0) {
|
||||
this.getWatcher();
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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() {
|
||||
return this.watcher;
|
||||
}
|
||||
|
||||
watchFiles(files) {
|
||||
if(Array.isArray(files)) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
_getFileContents(localpath, rootDir) {
|
||||
if(this.options.useCache && this.fileCache[localpath]) {
|
||||
return this.fileCache[localpath];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
let contents = fs.readFileSync(filepath, {
|
||||
encoding: this.options.encoding,
|
||||
});
|
||||
|
||||
if(this.options.useCache) {
|
||||
this.fileCache[localpath] = contents;
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
augmentContentWithNotifier(content, inlineContents = false, options = {}) {
|
||||
let { integrityHash, scriptContents } = options;
|
||||
if(!scriptContents) {
|
||||
scriptContents = this._getFileContents("./client/reload-client.js");
|
||||
}
|
||||
if(!integrityHash) {
|
||||
integrityHash = ssri.fromData(scriptContents);
|
||||
}
|
||||
|
||||
// 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">`}</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) {
|
||||
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)) {
|
||||
if(typeof result.status === "number") {
|
||||
res.statusCode = result.status;
|
||||
}
|
||||
|
||||
if(isPlainObject(result.headers)) {
|
||||
for(let name in result.headers) {
|
||||
res.setHeader(name, result.headers[name]);
|
||||
}
|
||||
}
|
||||
|
||||
return res.end(result.body || "");
|
||||
}
|
||||
|
||||
throw new Error(`Invalid return type from \`onRequest\` pattern for ${urlPatternString}: expected string or object.`);
|
||||
}
|
||||
}
|
||||
|
||||
if(req.url === `/${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"));
|
||||
return res.end(this._getFileContents("./node_modules/morphdom/dist/morphdom-esm.js", path.resolve(".")));
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// Content-Range request, probably Safari trying to stream video
|
||||
if (req.headers.range) {
|
||||
return send(req, match.filepath).pipe(res);
|
||||
}
|
||||
|
||||
if (match.statusCode === 200 && match.filepath) {
|
||||
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 devip()) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
return this._server;
|
||||
}
|
||||
|
||||
_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 `serverUrl` property not yet available.");
|
||||
}
|
||||
|
||||
let { port } = this._server.address();
|
||||
return `${this._serverProtocol}//${host}:${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 updateServer = new WebSocketServer({
|
||||
// includes the port
|
||||
server: this.server,
|
||||
});
|
||||
|
||||
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.
|
||||
_closeServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close(err => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async close() {
|
||||
// Prevent multiple invocations.
|
||||
if (this?._isClosing) {
|
||||
return;
|
||||
}
|
||||
this._isClosing = true;
|
||||
|
||||
// TODO would be awesome to set a delayed redirect when port changed to redirect to new _server_
|
||||
this.sendUpdateNotification({
|
||||
type: "eleventy.status",
|
||||
status: "disconnected",
|
||||
});
|
||||
|
||||
if(this.updateServer) {
|
||||
// Close all existing WS connections.
|
||||
this.updateServer?.clients.forEach(socket => socket.close());
|
||||
await this._closeServer(this.updateServer);
|
||||
}
|
||||
|
||||
if(this._server?.listening) {
|
||||
await this._closeServer(this.server);
|
||||
}
|
||||
|
||||
if(this._watcher) {
|
||||
await this._watcher.close();
|
||||
delete this._watcher;
|
||||
}
|
||||
|
||||
delete this._isClosing;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// [{ 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"))) {
|
||||
// all 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;
|
129
node_modules/@11ty/eleventy-dev-server/server/wrapResponse.js
generated
vendored
Normal file
129
node_modules/@11ty/eleventy-dev-server/server/wrapResponse.js
generated
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
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 && contentType.startsWith("text/html")) {
|
||||
if(this._wrappedTransformHtml && typeof this._wrappedTransformHtml === "function") {
|
||||
result = this._wrappedTransformHtml(result);
|
||||
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 {
|
||||
// Buffers
|
||||
for(let headers of this._wrappedHeaders) {
|
||||
this._wrappedOriginalWriteHead(...headers);
|
||||
}
|
||||
|
||||
if(data) {
|
||||
this._wrappedOriginalWrite(data, encoding);
|
||||
}
|
||||
return this._wrappedOriginalEnd(callback);
|
||||
}
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
module.exports = wrapResponse;
|
Reference in New Issue
Block a user