diff --git a/package.json b/package.json index 4606b77..ff8bdf8 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "mime": "^3.0.0", "minimist": "^1.2.8", "morphdom": "^2.7.0", + "netlify-redirect-parser": "^14.1.1", + "netlify-redirector": "^0.4.0", "please-upgrade-node": "^3.2.0", "ssri": "^8.0.1", "ws": "^8.13.0" diff --git a/server.js b/server.js index aad4c38..06cbd39 100644 --- a/server.js +++ b/server.js @@ -45,7 +45,7 @@ class EleventyDevServer { debug("Creating new Dev Server instance.") this.name = name; this.normalizeOptions(options); - + this.fileCache = {}; // Directory to serve if(!dir) { @@ -85,10 +85,10 @@ class EleventyDevServer { // 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, @@ -100,7 +100,7 @@ class EleventyDevServer { 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]); @@ -430,6 +430,89 @@ class EleventyDevServer { next(); } + async netlifyRedirectMiddleware(req, res, next) { + if (req.netlifyRedirectHandled) { + return next(); + } + req.netlifyRedirectHandled = true; + const matcher = await this.getNetlifyRedirectMatcher(); + + // We need some valid URL here. Localhost is used as a placeholder. + const reqUrl = new URL(req.url, "http://localhost"); + const match = matcher.match({ + scheme: reqUrl.protocol.replace(/:.*$/, ""), + host: reqUrl.hostname, + path: decodeURIComponent(reqUrl.pathname), + query: reqUrl.search.slice(1), + }); + + // Avoid recursive matches + if (match && req.url !== match.to) { + // This is, why we can't extract this into a separate module. + // This just rewrites the url of the request and + // treats it as a new request in the request handler. + req.url = match.to; + this.onRequestHandler(req, res); + } else { + next(); + } + } + + // Inspired by https://github.com/netlify/cli/blob/0f7ac190f9e1c7ed056d2feb1a1834c8305a048a/src/utils/rules-proxy.mjs + async getNetlifyRedirectMatcher() { + // Importing dynamically, because these modules are ESM based. + // This also means that they are cached between requests. + const redirectParser = await import("netlify-redirect-parser"); + const redirector = (await import("netlify-redirector")).default; + // Maybe some caching can be done here? + // Currently they are loaded per request, because redirects can be generated in the output folder + const { redirects, errors } = await redirectParser.parseAllRedirects({ + redirectsFiles: [path.join(this.dir, "_redirects")], + netlifyConfigPath: path.join(".", "netlify.toml"), + }); + + if (errors.length) { + this.logger.error(errors); + } + + // `netlify-redirector` does not handle the same shape as the `netlify-redirect-parser` provides: + // - `from` is called `origin` + // - `query` is called `params` + // - `conditions.role|country|language` are capitalized + const normalizeRedirect = function ({ + conditions: { country, language, role, ...conditions }, + from, + query, + signed, + ...redirect + }) { + return { + ...redirect, + origin: from, + params: query, + conditions: { + ...conditions, + ...(role && { Role: role }), + ...(country && { Country: country }), + ...(language && { Language: language }), + }, + ...(signed && { + sign: { + jwt_secret: signed, + }, + }), + }; + }; + + const normRedirects = redirects.map(normalizeRedirect); + const matcher = await redirector.parseJSON( + JSON.stringify(normRedirects), + {} + ); + + return matcher; + } + // This runs at the end of the middleware chain eleventyProjectMiddleware(req, res) { // Known issue with `finalhandler` and HTTP/2: @@ -508,7 +591,7 @@ class EleventyDevServer { res.setHeader("Content-Security-Policy", `script-src '${integrityHash}'`); } return this.augmentContentWithNotifier(content, res.statusCode !== 200, { - scriptContents, + scriptContents, integrityHash }); } @@ -518,6 +601,7 @@ class EleventyDevServer { let middlewares = this.options.middleware || []; middlewares = middlewares.slice(); + middlewares.push(this.netlifyRedirectMiddleware); // 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 @@ -786,13 +870,13 @@ class EleventyDevServer { build.templates = build.templates .filter(entry => { if(!this.options.domDiff) { - // Don’t include any files if the dom diffing option is disabled - return false; - } + // 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); - }); + // Filter to only include watched templates that were updated + return (files || []).includes(entry.inputPath); + }); } this.sendUpdateNotification({ diff --git a/server/wrapResponse.js b/server/wrapResponse.js index 72c8540..449c420 100644 --- a/server/wrapResponse.js +++ b/server/wrapResponse.js @@ -12,6 +12,11 @@ function getContentType(headers) { // Inspired by `resp-modifier` https://github.com/shakyShane/resp-modifier/blob/4a000203c9db630bcfc3b6bb8ea2abc090ae0139/index.js function wrapResponse(resp, transformHtml) { + // Response is already wrapped + if (resp._wrappedOriginalWrite) { + return resp; + } + resp._wrappedOriginalWrite = resp.write; resp._wrappedOriginalWriteHead = resp.writeHead; resp._wrappedOriginalEnd = resp.end;