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 // 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 = ``; if (content.includes("")) { return content.replace("", `${script}`); } // If the HTML document contains an importmap, insert the module script after the importmap element let importMapRegEx = /