diff --git a/lib/core/middleware/error.js b/lib/core/middleware/error.js new file mode 100644 index 0000000000..f9878bce7a --- /dev/null +++ b/lib/core/middleware/error.js @@ -0,0 +1,136 @@ +const Youch = require('@nuxtjs/youch') +const { SourceMapConsumer } = require('source-map') +const { join, resolve } = require('path') +const { readFile } = require('fs-extra') + +module.exports = function errorMiddleware(err, req, res, next) { + // ensure statusCode, message and name fields + err.statusCode = err.statusCode || 500 + err.message = err.message || 'Nuxt Server Error' + err.name = (!err.name || err.name === 'Error') ? 'NuxtServerError' : err.name + + // We hide actual errors from end users, so show them on server logs + if (err.statusCode !== 404) { + console.error(err) // eslint-disable-line no-console + } + + const sendResponse = (content, type = 'text/html') => { + // Set Headers + res.statusCode = err.statusCode + res.statusMessage = err.name + res.setHeader('Content-Type', type + '; charset=utf-8') + res.setHeader('Content-Length', Buffer.byteLength(content)) + + // Send Response + res.end(content, 'utf-8') + } + + // Check if request accepts JSON + const hasReqHeader = (header, includes) => req.headers[header] && req.headers[header].toLowerCase().includes(includes) + const isJson = hasReqHeader('accept', 'application/json') || hasReqHeader('user-agent', 'curl/') + + // Use basic errors when debug mode is disabled + if (!this.options.debug) { + // Json format is compatible with Youch json responses + const json = { + status: err.statusCode, + message: err.message, + name: err.name + } + if (isJson) { + sendResponse(JSON.stringify(json, undefined, 2), 'text/json') + return + } + const html = this.resources.errorTemplate(json) + sendResponse(html) + return + } + + // Show stack trace + const youch = new Youch(err, req, readSource.bind(this)) + if (isJson) { + youch.toJSON().then(json => { sendResponse(JSON.stringify(json, undefined, 2), 'text/json') }) + } else { + youch.toHTML().then(html => { sendResponse(html) }) + } +} + +async function readSource(frame) { + const serverBundle = this.resources.serverBundle + + // Remove webpack:/// & query string from the end + const sanitizeName = name => name ? name.replace('webpack:///', '').split('?')[0] : '' + + // SourceMap Support for SSR Bundle + if (serverBundle && serverBundle.maps[frame.fileName]) { + // Initialize smc cache + if (!serverBundle.$maps) { + serverBundle.$maps = {} + } + + // Read SourceMap object + const smc = serverBundle.$maps[frame.fileName] || new SourceMapConsumer(serverBundle.maps[frame.fileName]) + serverBundle.$maps[frame.fileName] = smc + + // Try to find original position + const { line, column, name, source } = smc.originalPositionFor({ + line: frame.getLineNumber() || 0, + column: frame.getColumnNumber() || 0, + bias: SourceMapConsumer.LEAST_UPPER_BOUND + }) + if (line) { + frame.lineNumber = line + } + /* istanbul ignore if */ + if (column) { + frame.columnNumber = column + } + /* istanbul ignore if */ + if (name) { + frame.functionName = name + } + if (source) { + frame.fileName = sanitizeName(source) + + // Source detected, try to get original source code + const contents = smc.sourceContentFor(source) + if (contents) { + frame.contents = contents + } + } + } + + // Return if fileName is still unknown + if (!frame.fileName) { + return + } + + frame.fileName = sanitizeName(frame.fileName) + + // Try to read from SSR bundle files + if (serverBundle && serverBundle.files[frame.fileName]) { + frame.contents = serverBundle.files[frame.fileName] + return + } + + // Possible paths for file + const searchPath = [ + this.options.rootDir, + join(this.options.buildDir, 'dist'), + this.options.srcDir, + this.options.buildDir + ] + + // Scan filesystem for real path + for (let pathDir of searchPath) { + let fullPath = resolve(pathDir, frame.fileName) + let source = await readFile(fullPath, 'utf-8').catch(() => null) + if (source) { + if (!frame.contents) { + frame.contents = source + } + frame.fullPath = fullPath + return + } + } +} diff --git a/lib/core/middleware/nuxt.js b/lib/core/middleware/nuxt.js new file mode 100644 index 0000000000..8c6e9831ec --- /dev/null +++ b/lib/core/middleware/nuxt.js @@ -0,0 +1,66 @@ +const generateETag = require('etag') +const fresh = require('fresh') + +const { getContext } = require('../common/utils') + +module.exports = async function nuxtMiddleware(req, res, next) { + // Get context + const context = getContext(req, res) + + res.statusCode = 200 + try { + const result = await this.renderRoute(req.url, context) + await this.nuxt.callHook('render:route', req.url, result) + const { html, error, redirected, resourceHints } = result + + if (redirected) { + return html + } + if (error) { + res.statusCode = context.nuxt.error.statusCode || 500 + } + + // Add ETag header + if (!error && this.options.render.etag) { + const etag = generateETag(html, this.options.render.etag) + if (fresh(req.headers, { etag })) { + res.statusCode = 304 + res.end() + return + } + res.setHeader('ETag', etag) + } + + // HTTP2 push headers + if (!error && this.options.render.http2.push) { + // Parse resourceHints to extract HTTP.2 prefetch/push headers + // https://w3c.github.io/preload/#server-push-http-2 + const regex = /link rel="([^"]*)" href="([^"]*)" as="([^"]*)"/g + const pushAssets = [] + let m + while (m = regex.exec(resourceHints)) { // eslint-disable-line no-cond-assign + const [, rel, href, as] = m + if (rel === 'preload') { + pushAssets.push(`<${href}>; rel=${rel}; as=${as}`) + } + } + // Pass with single Link header + // https://blog.cloudflare.com/http-2-server-push-with-multiple-assets-per-link-header + res.setHeader('Link', pushAssets.join(',')) + } + + // Send response + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.setHeader('Content-Length', Buffer.byteLength(html)) + res.end(html, 'utf8') + return html + } catch (err) { + /* istanbul ignore if */ + if (context && context.redirected) { + console.error(err) // eslint-disable-line no-console + return err + } + + next(err) + } +} diff --git a/lib/core/middleware/open-in-editor.js b/lib/core/middleware/open-in-editor.js new file mode 100644 index 0000000000..36d1a5658e --- /dev/null +++ b/lib/core/middleware/open-in-editor.js @@ -0,0 +1,23 @@ + +const openInEditor = require('open-in-editor') + +module.exports = function openInEditorMiddleware(req, res) { + // Lazy load open-in-editor + const editor = openInEditor.configure(this.options.editor) + + // Parse Query + const query = req.url.split('?')[1].split('&').reduce((q, part) => { + const s = part.split('=') + q[s[0]] = decodeURIComponent(s[1]) + return q + }, {}) + + // eslint-disable-next-line no-console + console.log('[open in editor]', query.file) + + editor.open(query.file).then(() => { + res.end('opened in editor!') + }).catch(err => { + res.end(err) + }) +} diff --git a/lib/core/renderer.js b/lib/core/renderer.js index c7428b71be..57e8d79fdc 100644 --- a/lib/core/renderer.js +++ b/lib/core/renderer.js @@ -1,21 +1,23 @@ const ansiHTML = require('ansi-html') const serialize = require('serialize-javascript') -const generateETag = require('etag') -const fresh = require('fresh') + const serveStatic = require('serve-static') const compression = require('compression') const _ = require('lodash') const { join, resolve } = require('path') const fs = require('fs-extra') const { createBundleRenderer } = require('vue-server-renderer') -const { getContext, setAnsiColors, isUrl, waitFor } = require('../common/utils') +const { setAnsiColors, isUrl, waitFor } = require('../common/utils') const Debug = require('debug') -const Youch = require('@nuxtjs/youch') -const { SourceMapConsumer } = require('source-map') + const connect = require('connect') const { Options } = require('../common') const MetaRenderer = require('./meta') +const errorMiddleware = require('./middleware/error') +const nuxtMiddleware = require('./middleware/nuxt') +const openInEditorMiddleware = require('./middleware/open-in-editor') + const debug = Debug('nuxt:render') debug.color = 4 // Force blue color @@ -223,28 +225,10 @@ module.exports = class Renderer { } // open in editor for debug mode only - const _this = this if (this.options.debug && this.options.dev) { this.useMiddleware({ path: '__open-in-editor', - handler(req, res) { - // Lazy load open-in-editor - const openInEditor = require('open-in-editor') - const editor = openInEditor.configure(_this.options.editor) - // Parse Query - const query = req.url.split('?')[1].split('&').reduce((q, part) => { - const s = part.split('=') - q[s[0]] = decodeURIComponent(s[1]) - return q - }, {}) - // eslint-disable-next-line no-console - console.log('[open in editor]', query.file) - editor.open(query.file).then(() => { - res.end('opened in editor!') - }).catch(err => { - res.end(err) - }) - } + handler: openInEditorMiddleware.bind(this) }) } @@ -270,206 +254,12 @@ module.exports = class Renderer { }) // Finally use nuxtMiddleware - this.useMiddleware(this.nuxtMiddleware.bind(this)) + this.useMiddleware(nuxtMiddleware.bind(this)) // Error middleware for errors that occurred in middleware that declared above // Middleware should exactly take 4 arguments // https://github.com/senchalabs/connect#error-middleware - this.useMiddleware(this.errorMiddleware.bind(this)) - } - - async nuxtMiddleware(req, res, next) { - // Get context - const context = getContext(req, res) - - res.statusCode = 200 - try { - const result = await this.renderRoute(req.url, context) - await this.nuxt.callHook('render:route', req.url, result) - const { html, error, redirected, resourceHints } = result - - if (redirected) { - return html - } - if (error) { - res.statusCode = context.nuxt.error.statusCode || 500 - } - - // Add ETag header - if (!error && this.options.render.etag) { - const etag = generateETag(html, this.options.render.etag) - if (fresh(req.headers, { etag })) { - res.statusCode = 304 - res.end() - return - } - res.setHeader('ETag', etag) - } - - // HTTP2 push headers - if (!error && this.options.render.http2.push) { - // Parse resourceHints to extract HTTP.2 prefetch/push headers - // https://w3c.github.io/preload/#server-push-http-2 - const regex = /link rel="([^"]*)" href="([^"]*)" as="([^"]*)"/g - const pushAssets = [] - let m - while (m = regex.exec(resourceHints)) { // eslint-disable-line no-cond-assign - const [, rel, href, as] = m - if (rel === 'preload') { - pushAssets.push(`<${href}>; rel=${rel}; as=${as}`) - } - } - // Pass with single Link header - // https://blog.cloudflare.com/http-2-server-push-with-multiple-assets-per-link-header - res.setHeader('Link', pushAssets.join(',')) - } - - // Send response - res.setHeader('Content-Type', 'text/html; charset=utf-8') - res.setHeader('Content-Length', Buffer.byteLength(html)) - res.end(html, 'utf8') - return html - } catch (err) { - /* istanbul ignore if */ - if (context && context.redirected) { - console.error(err) // eslint-disable-line no-console - return err - } - - next(err) - } - } - - errorMiddleware(err, req, res, next) { - // ensure statusCode, message and name fields - err.statusCode = err.statusCode || 500 - err.message = err.message || 'Nuxt Server Error' - err.name = (!err.name || err.name === 'Error') ? 'NuxtServerError' : err.name - - // We hide actual errors from end users, so show them on server logs - if (err.statusCode !== 404) { - console.error(err) // eslint-disable-line no-console - } - - const sendResponse = (content, type = 'text/html') => { - // Set Headers - res.statusCode = err.statusCode - res.statusMessage = err.name - res.setHeader('Content-Type', type + '; charset=utf-8') - res.setHeader('Content-Length', Buffer.byteLength(content)) - - // Send Response - res.end(content, 'utf-8') - } - - // Check if request accepts JSON - const hasReqHeader = (header, includes) => req.headers[header] && req.headers[header].toLowerCase().includes(includes) - const isJson = hasReqHeader('accept', 'application/json') || hasReqHeader('user-agent', 'curl/') - - // Use basic errors when debug mode is disabled - if (!this.options.debug) { - // Json format is compatible with Youch json responses - const json = { - status: err.statusCode, - message: err.message, - name: err.name - } - if (isJson) { - sendResponse(JSON.stringify(json, undefined, 2), 'text/json') - return - } - const html = this.resources.errorTemplate(json) - sendResponse(html) - return - } - - // Show stack trace - const youch = new Youch(err, req, this.readSource.bind(this)) - if (isJson) { - youch.toJSON().then(json => { sendResponse(JSON.stringify(json, undefined, 2), 'text/json') }) - } else { - youch.toHTML().then(html => { sendResponse(html) }) - } - } - - async readSource(frame) { - const serverBundle = this.resources.serverBundle - - // Remove webpack:/// & query string from the end - const sanitizeName = name => name ? name.replace('webpack:///', '').split('?')[0] : '' - - // SourceMap Support for SSR Bundle - if (serverBundle && serverBundle.maps[frame.fileName]) { - // Initialize smc cache - if (!serverBundle.$maps) { - serverBundle.$maps = {} - } - - // Read SourceMap object - const smc = serverBundle.$maps[frame.fileName] || new SourceMapConsumer(serverBundle.maps[frame.fileName]) - serverBundle.$maps[frame.fileName] = smc - - // Try to find original position - const { line, column, name, source } = smc.originalPositionFor({ - line: frame.getLineNumber() || 0, - column: frame.getColumnNumber() || 0, - bias: SourceMapConsumer.LEAST_UPPER_BOUND - }) - if (line) { - frame.lineNumber = line - } - /* istanbul ignore if */ - if (column) { - frame.columnNumber = column - } - /* istanbul ignore if */ - if (name) { - frame.functionName = name - } - if (source) { - frame.fileName = sanitizeName(source) - - // Source detected, try to get original source code - const contents = smc.sourceContentFor(source) - if (contents) { - frame.contents = contents - } - } - } - - // Return if fileName is still unknown - if (!frame.fileName) { - return - } - - frame.fileName = sanitizeName(frame.fileName) - - // Try to read from SSR bundle files - if (serverBundle && serverBundle.files[frame.fileName]) { - frame.contents = serverBundle.files[frame.fileName] - return - } - - // Possible paths for file - const searchPath = [ - this.options.rootDir, - join(this.options.buildDir, 'dist'), - this.options.srcDir, - this.options.buildDir - ] - - // Scan filesystem for real path - for (let pathDir of searchPath) { - let fullPath = resolve(pathDir, frame.fileName) - let source = await fs.readFile(fullPath, 'utf-8').catch(() => null) - if (source) { - if (!frame.contents) { - frame.contents = source - } - frame.fullPath = fullPath - return - } - } + this.useMiddleware(errorMiddleware.bind(this)) } async renderRoute(url, context = {}) {