808 lines
23 KiB
JavaScript
808 lines
23 KiB
JavaScript
|
const pkg = require("./package.json");
|
|||
|
const path = require("path");
|
|||
|
const fs = require("fs");
|
|||
|
const finalhandler = require("finalhandler");
|
|||
|
const WebSocket = require("ws");
|
|||
|
const { WebSocketServer } = WebSocket;
|
|||
|
const mime = require("mime");
|
|||
|
const ssri = require("ssri");
|
|||
|
const devip = require("dev-ip");
|
|||
|
const chokidar = require("chokidar");
|
|||
|
const { TemplatePath } = require("@11ty/eleventy-utils");
|
|||
|
|
|||
|
const debug = require("debug")("EleventyDevServer");
|
|||
|
|
|||
|
const wrapResponse = require("./server/wrapResponse.js");
|
|||
|
|
|||
|
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
|
|||
|
|
|||
|
// 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, "index.html");
|
|||
|
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: url + "/",
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// /resource/ => redirect to /resource
|
|||
|
if (htmlExists && url.endsWith("/")) {
|
|||
|
return {
|
|||
|
statusCode: 301,
|
|||
|
url: url.substring(0, url.length - 1),
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
return {
|
|||
|
statusCode: 404,
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
_getFileContents(localpath, rootDir, useCache = true) {
|
|||
|
if(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(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);
|
|||
|
|
|||
|
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);
|
|||
|
}
|
|||
|
|
|||
|
eleventyDevServerMiddleware(req, res, next) {
|
|||
|
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) {
|
|||
|
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();
|
|||
|
}
|
|||
|
|
|||
|
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 { port } = this._server.address();
|
|||
|
|
|||
|
let hostsStr = "";
|
|||
|
if(this.options.showAllHosts) {
|
|||
|
// TODO what happens when the cert doesn’t cover non-localhost hosts?
|
|||
|
let hosts = devip().map(host => `${this._serverProtocol}//${host}:${port}${this.options.pathPrefix} or`);
|
|||
|
hostsStr = hosts.join(" ") + " ";
|
|||
|
}
|
|||
|
|
|||
|
let startBenchmark = ""; // this.start ? ` ready in ${Date.now() - this.start}ms` : "";
|
|||
|
this.logger.info(`Server at ${hostsStr}${this._serverProtocol}//localhost:${port}${this.options.pathPrefix}${this.options.showVersion ? ` (v${pkg.version})` : ""}${startBenchmark}`);
|
|||
|
});
|
|||
|
|
|||
|
return this._server;
|
|||
|
}
|
|||
|
|
|||
|
_serverListen(port) {
|
|||
|
this.server.listen({
|
|||
|
port,
|
|||
|
});
|
|||
|
}
|
|||
|
|
|||
|
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));
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
close() {
|
|||
|
// 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.server) {
|
|||
|
this.server.close();
|
|||
|
}
|
|||
|
if(this.updateServer) {
|
|||
|
this.updateServer.close();
|
|||
|
}
|
|||
|
if(this._watcher) {
|
|||
|
this._watcher.close();
|
|||
|
delete this._watcher;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
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("/index.html")) {
|
|||
|
urls.push(path.slice(0, -1 * "index.html".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;
|