Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: add error page to edge functions for local dev #5070

Merged
merged 9 commits into from Oct 3, 2022
21 changes: 4 additions & 17 deletions src/lib/functions/synchronous.js
@@ -1,9 +1,8 @@
// @ts-check
const { Buffer } = require('buffer')
const { readFile } = require('fs').promises
const { join } = require('path')

const { NETLIFYDEVERR } = require('../../utils')
const renderErrorTemplate = require('../render-error-remplate')

const { detectAwsSdkError } = require('./utils')

Expand Down Expand Up @@ -47,25 +46,13 @@ const formatLambdaLocalError = (err, acceptsHtml) =>
})
: `${err.errorType}: ${err.errorMessage}\n ${err.stackTrace.join('\n ')}`

let errorTemplateFile

const renderErrorTemplate = async (errString) => {
const regexPattern = /<!--@ERROR-DETAILS-->/g
const templatePath = './templates/function-error.html'

try {
errorTemplateFile = errorTemplateFile || (await readFile(join(__dirname, templatePath), 'utf-8'))
return errorTemplateFile.replace(regexPattern, errString)
} catch {
return errString
}
}

const processRenderedResponse = async (err, request) => {
const acceptsHtml = request.headers && request.headers.accept && request.headers.accept.includes('text/html')
const errorString = typeof err === 'string' ? err : formatLambdaLocalError(err, acceptsHtml)

return acceptsHtml ? await renderErrorTemplate(errorString) : errorString
return acceptsHtml
? await renderErrorTemplate(errorString, './templates/function-error.html', 'function')
: errorString
}

const handleErr = async (err, request, response) => {
Expand Down
17 changes: 17 additions & 0 deletions src/lib/render-error-remplate.js
@@ -0,0 +1,17 @@
const { readFile } = require('fs').promises
const { join } = require('path')

let errorTemplateFile

const renderErrorTemplate = async (errString, templatePath, functionType) => {
const errorDetailsRegex = /<!--@ERROR-DETAILS-->/g
const functionTypeRegex = /<!--@FUNCTION-TYPE-->/g
try {
errorTemplateFile = errorTemplateFile || (await readFile(join(__dirname, templatePath), 'utf-8'))
return errorTemplateFile.replace(errorDetailsRegex, errString).replace(functionTypeRegex, functionType)
} catch {
return errString
}
}

module.exports = renderErrorTemplate
Expand Up @@ -24,6 +24,7 @@
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
color: var(--colorDefaultTextColor);
margin: 0;
}

main {
Expand Down Expand Up @@ -196,7 +197,9 @@ <h1>
fill="#900B31"
/>
</svg>
This function has crashed
This
<!--@FUNCTION-TYPE-->
has crashed
</h1>
<p>An unhandled error in the function code triggered the following message:</p>
<p class="error-message-details hidden inline-code">
Expand All @@ -210,34 +213,7 @@ <h1>
<h3>Stack trace</h3>
<pre><code></code></pre>
</div>

<div class="request-id-container hidden">
<h3 class="connection-details">Connection details</h3>
<p>
Netlify internal ID: <span class="inline-code request-id"><!--@REQUEST-ID--></span>
</p>
</div>
</section>

<section class="next-steps">
<h1>Next steps</h1>
<ul>
<li>Site visitors: Contact the site owner or try again.</li>
<li>
Site owners: Review the
<a class="fn-logs-link" href="https://docs.netlify.com/functions/logs/#access-function-logs" target="_blank"
>function logs</a
>
for more info.
</li>
</ul>
</section>

<div class="hidden">
<span class="site-id"><!--@SITE-ID--></span>
<span class="deploy-id"><!--@DEPLOY-ID--></span>
<span class="fn-name"><!--@FUNCTION-NAME--></span>
</div>
<script>
/* eslint eslint-comments/no-use: off */
// eslint-disable-next-line func-names
Expand All @@ -248,17 +224,6 @@ <h1>Next steps</h1>
const errorMessageElement = document.querySelector('.error-message')
const stackTraceElement = document.querySelector('.stack-trace')
const stackTraceCodeElement = document.querySelector('.stack-trace code')
const requestIdContainerElement = document.querySelector('.request-id-container')
const requestIdElement = document.querySelector('.request-id')
const siteIdVal = document.querySelector('.site-id').textContent.trim()
const deployIdVal = document.querySelector('.deploy-id').textContent.trim()
const fnNameVal = document.querySelector('.fn-name').textContent.trim()
const logsLink = document.querySelector('.fn-logs-link')

// NO request ID Option Provided - only expected to happen in local dev scenarios
if (requestIdElement.textContent.trim().length !== 0) {
requestIdContainerElement.classList.remove('hidden')
}

// Enriching the error details
let parsedErrorDetails
Expand Down Expand Up @@ -339,19 +304,6 @@ <h1>Next steps</h1>
} else {
stackTraceCodeElement.remove()
}

// only create the dynamic link if we have all of the relevant details
// Add a defensive check for "undefined" accidentally being put into the template
if (
deployIdVal &&
deployIdVal !== 'undefined' &&
siteIdVal &&
siteIdVal !== 'undefined' &&
fnNameVal &&
siteIdVal !== 'undefined'
) {
logsLink.href = `https://app.netlify.com/site-fn-logs/${siteIdVal}/${deployIdVal}/${fnNameVal}`
}
})()
</script>
</main>
Expand Down
38 changes: 37 additions & 1 deletion src/utils/proxy.js
Expand Up @@ -4,6 +4,8 @@ const { readFile } = require('fs').promises
const http = require('http')
const https = require('https')
const path = require('path')
const util = require('util')
const zlib = require('zlib')

const contentType = require('content-type')
const cookie = require('cookie')
Expand All @@ -20,14 +22,32 @@ const toReadableStream = require('to-readable-stream')

const edgeFunctions = require('../lib/edge-functions')
const { fileExistsAsync, isFileAsync } = require('../lib/fs')
const renderErrorTemplate = require('../lib/render-error-remplate')

const { NETLIFYDEVLOG, NETLIFYDEVWARN } = require('./command-helpers')
const { createStreamPromise } = require('./create-stream-promise')
const { headersForPath, parseHeaders } = require('./headers')
const { createRewriter, onChanges } = require('./rules-proxy')

const decompress = util.promisify(zlib.gunzip)
const shouldGenerateETag = Symbol('Internal: response should generate ETag')

const formatEdgeFunctionError = (errorBuffer, acceptsHtml) => {
const {
error: { message, name, stack },
} = JSON.parse(errorBuffer.toString())

if (!acceptsHtml) {
return `${name}: ${message}\n ${stack}`
}

return JSON.stringify({
errorType: name,
errorMessage: message,
trace: stack.split('\\n'),
})
}

const isInternal = function (url) {
return url.startsWith('/.netlify/')
}
Expand Down Expand Up @@ -362,7 +382,7 @@ const initializeProxy = async function ({ configPath, distDir, port, projectDir
responseData.push(data)
})

proxyRes.on('end', function onEnd() {
proxyRes.on('end', async function onEnd() {
const responseBody = Buffer.concat(responseData)

let responseStatus = req.proxyOptions.status || proxyRes.statusCode
Expand All @@ -386,6 +406,22 @@ const initializeProxy = async function ({ configPath, distDir, port, projectDir
res.setHeader(key, val)
})

const isUncaughtError = proxyRes.headers['x-nf-uncaught-error'] === '1'

if (edgeFunctions.isEdgeFunctionsRequest(req) && isUncaughtError) {
const acceptsHtml = req.headers && req.headers.accept && req.headers.accept.includes('text/html')
const decompressedBody = await decompress(responseBody)
khendrikse marked this conversation as resolved.
Show resolved Hide resolved
const formattedBody = formatEdgeFunctionError(decompressedBody, acceptsHtml)
const errorResponse = acceptsHtml
? await renderErrorTemplate(formattedBody, './templates/function-error.html', 'edge function')
: formattedBody
const contentLength = Buffer.from(errorResponse, 'utf8').byteLength

res.setHeader('content-length', contentLength)
res.write(errorResponse)
return res.end()
}

res.writeHead(responseStatus, proxyRes.headers)

if (responseStatus !== 304) {
Expand Down