diff --git a/lib/config.js b/lib/config.js index dfb4e19..e926b61 100644 --- a/lib/config.js +++ b/lib/config.js @@ -19,12 +19,11 @@ const NEXT_CONFIG_PATH = join(".", "next.config.js"); // This is the folder that NextJS builds to; default is .next const NEXT_DIST_DIR = getNextDistDir({ nextConfigPath: NEXT_CONFIG_PATH }); +// This is the folder with templates for Netlify Functions +const TEMPLATES_DIR = join(__dirname, "templates"); + // This is the Netlify Function template that wraps all SSR pages -const FUNCTION_TEMPLATE_PATH = join( - __dirname, - "templates", - "netlifyFunction.js" -); +const FUNCTION_TEMPLATE_PATH = join(TEMPLATES_DIR, "netlifyFunction.js"); // This is the file where custom redirects can be configured const CUSTOM_REDIRECTS_PATH = join(".", "_redirects"); @@ -35,6 +34,7 @@ module.exports = { PUBLIC_PATH, NEXT_CONFIG_PATH, NEXT_DIST_DIR, + TEMPLATES_DIR, FUNCTION_TEMPLATE_PATH, CUSTOM_REDIRECTS_PATH, }; diff --git a/lib/helpers/setupNetlifyFunctionForPage.js b/lib/helpers/setupNetlifyFunctionForPage.js index 86d8c8d..a5f4cb4 100644 --- a/lib/helpers/setupNetlifyFunctionForPage.js +++ b/lib/helpers/setupNetlifyFunctionForPage.js @@ -1,6 +1,10 @@ const { copySync } = require("fs-extra"); const { join } = require("path"); -const { NEXT_DIST_DIR, FUNCTION_TEMPLATE_PATH } = require("../config"); +const { + NEXT_DIST_DIR, + TEMPLATES_DIR, + FUNCTION_TEMPLATE_PATH, +} = require("../config"); const getNetlifyFunctionName = require("./getNetlifyFunctionName"); // Create a Netlify Function for the page with the given file path @@ -19,8 +23,21 @@ const setupNetlifyFunctionForPage = ({ filePath, functionsPath }) => { errorOnExist: true, }); + // Copy function helpers + const functionHelpers = [ + "renderNextPage.js", + "createRequestObject.js", + "createResponseObject.js", + ]; + functionHelpers.forEach((helper) => { + copySync(join(TEMPLATES_DIR, helper), join(functionDirectory, helper), { + overwrite: false, + errorOnExist: true, + }); + }); + // Copy page - const nextPageCopyPath = join(functionDirectory, "nextJsPage.js"); + const nextPageCopyPath = join(functionDirectory, "nextPage.js"); copySync(join(NEXT_DIST_DIR, "serverless", filePath), nextPageCopyPath, { overwrite: false, errorOnExist: true, diff --git a/lib/templates/createRequestObject.js b/lib/templates/createRequestObject.js new file mode 100644 index 0000000..39ce1d6 --- /dev/null +++ b/lib/templates/createRequestObject.js @@ -0,0 +1,81 @@ +const Stream = require("stream"); +const queryString = require("querystring"); +const http = require("http"); + +// Mock a HTTP IncomingMessage object from the Netlify Function event parameters +// Based on API Gateway Lambda Compat +// Source: https://github.com/serverless-nextjs/serverless-next.js/blob/master/packages/compat-layers/apigw-lambda-compat/lib/compatLayer.js + +const createRequestObject = ({ event }) => { + const { + requestContext = {}, + path = "", + multiValueQueryStringParameters, + pathParameters, + httpMethod, + multiValueHeaders = {}, + body, + isBase64Encoded, + } = event; + + const newStream = new Stream.Readable(); + const req = Object.assign(newStream, http.IncomingMessage.prototype); + req.url = + (requestContext.path || path || "").replace( + new RegExp("^/" + requestContext.stage), + "" + ) || "/"; + + let qs = ""; + + if (multiValueQueryStringParameters) { + qs += queryString.stringify(multiValueQueryStringParameters); + } + + if (pathParameters) { + const pathParametersQs = queryString.stringify(pathParameters); + + if (qs.length > 0) { + qs += `&${pathParametersQs}`; + } else { + qs += pathParametersQs; + } + } + + const hasQueryString = qs.length > 0; + + if (hasQueryString) { + req.url += `?${qs}`; + } + + req.method = httpMethod; + req.rawHeaders = []; + req.headers = {}; + + for (const key of Object.keys(multiValueHeaders)) { + for (const value of multiValueHeaders[key]) { + req.rawHeaders.push(key); + req.rawHeaders.push(value); + } + req.headers[key.toLowerCase()] = multiValueHeaders[key].toString(); + } + + req.getHeader = (name) => { + return req.headers[name.toLowerCase()]; + }; + req.getHeaders = () => { + return req.headers; + }; + + req.connection = {}; + + if (body) { + req.push(body, isBase64Encoded ? "base64" : undefined); + } + + req.push(null); + + return req; +}; + +module.exports = createRequestObject; diff --git a/lib/templates/createResponseObject.js b/lib/templates/createResponseObject.js new file mode 100644 index 0000000..23335a4 --- /dev/null +++ b/lib/templates/createResponseObject.js @@ -0,0 +1,81 @@ +const Stream = require("stream"); + +// Mock a HTTP ServerResponse object that returns a Netlify Function-compatible +// response via the onResEnd callback when res.end() is called. +// Based on API Gateway Lambda Compat +// Source: https://github.com/serverless-nextjs/serverless-next.js/blob/master/packages/compat-layers/apigw-lambda-compat/lib/compatLayer.js + +const createResponseObject = ({ onResEnd }) => { + const response = { + isBase64Encoded: true, + multiValueHeaders: {}, + }; + + const res = new Stream(); + Object.defineProperty(res, "statusCode", { + get() { + return response.statusCode; + }, + set(statusCode) { + response.statusCode = statusCode; + }, + }); + res.headers = {}; + res.writeHead = (status, headers) => { + response.statusCode = status; + if (headers) res.headers = Object.assign(res.headers, headers); + }; + res.write = (chunk) => { + if (!response.body) { + response.body = Buffer.from(""); + } + + response.body = Buffer.concat([ + Buffer.isBuffer(response.body) + ? response.body + : Buffer.from(response.body), + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), + ]); + }; + res.setHeader = (name, value) => { + res.headers[name.toLowerCase()] = value; + }; + res.removeHeader = (name) => { + delete res.headers[name.toLowerCase()]; + }; + res.getHeader = (name) => { + return res.headers[name.toLowerCase()]; + }; + res.getHeaders = () => { + return res.headers; + }; + res.hasHeader = (name) => { + return !!res.getHeader(name); + }; + res.end = (text) => { + if (text) res.write(text); + if (!res.statusCode) { + res.statusCode = 200; + } + + if (response.body) { + response.body = Buffer.from(response.body).toString("base64"); + } + response.multiValueHeaders = res.headers; + res.writeHead(response.statusCode); + + // Convert all multiValueHeaders into arrays + for (const key of Object.keys(response.multiValueHeaders)) { + if (!Array.isArray(response.multiValueHeaders[key])) { + response.multiValueHeaders[key] = [response.multiValueHeaders[key]]; + } + } + + // Call onResEnd handler with the response object + onResEnd(response); + }; + + return res; +}; + +module.exports = createResponseObject; diff --git a/lib/templates/netlifyFunction.js b/lib/templates/netlifyFunction.js index 83e4799..0fc926e 100644 --- a/lib/templates/netlifyFunction.js +++ b/lib/templates/netlifyFunction.js @@ -1,38 +1,10 @@ // TEMPLATE: This file will be copied to the Netlify functions directory when // running next-on-netlify -// Compatibility wrapper for NextJS page -const compat = require("next-aws-lambda"); -// Load the NextJS page -const page = require("./nextJsPage"); - -// next-aws-lambda is made for AWS. There are some minor differences between -// Netlify and AWS which we resolve here. -const callbackHandler = (callback) => - // The callbackHandler wraps the callback - (argument, response) => { - // Convert header values to string. Netlify does not support integers as - // header values. See: https://github.com/netlify/cli/issues/451 - Object.keys(response.multiValueHeaders).forEach((key) => { - response.multiValueHeaders[key] = response.multiValueHeaders[ - key - ].map((value) => String(value)); - }); - - response.multiValueHeaders["Cache-Control"] = ["no-cache"]; - - // Invoke callback - callback(argument, response); - }; - -exports.handler = (event, context, callback) => { - // Enable support for base64 encoding. - // This is used by next-aws-lambda to determine whether to encode the response - // body as base64. - if (!process.env.hasOwnProperty("BINARY_SUPPORT")) { - process.env.BINARY_SUPPORT = "yes"; - } +// Render function for the Next.js page +const renderNextPage = require("./renderNextPage"); +exports.handler = async (event, context, callback) => { // x-forwarded-host is undefined on Netlify for proxied apps that need it // fixes https://github.com/netlify/next-on-netlify/issues/46 if (!event.multiValueHeaders.hasOwnProperty("x-forwarded-host")) { @@ -43,16 +15,22 @@ exports.handler = (event, context, callback) => { const { path } = event; console.log("[request]", path); - // Render the page - compat(page)( - { - ...event, - // Required. Otherwise, compat() will complain - requestContext: {}, - }, - context, - // Wrap the Netlify callback, so that we can resolve differences between - // Netlify and AWS (which next-aws-lambda optimizes for) - callbackHandler(callback) - ); + // Render the Next.js page + const response = await renderNextPage({ + ...event, + // Required. Otherwise, reqResMapper will complain + requestContext: {}, + }); + + // Convert header values to string. Netlify does not support integers as + // header values. See: https://github.com/netlify/cli/issues/451 + Object.keys(response.multiValueHeaders).forEach((key) => { + response.multiValueHeaders[key] = response.multiValueHeaders[ + key + ].map((value) => String(value)); + }); + + response.multiValueHeaders["Cache-Control"] = ["no-cache"]; + + callback(null, response); }; diff --git a/lib/templates/renderNextPage.js b/lib/templates/renderNextPage.js new file mode 100644 index 0000000..38d3f13 --- /dev/null +++ b/lib/templates/renderNextPage.js @@ -0,0 +1,32 @@ +// Load the NextJS page +const nextPage = require("./nextPage"); +const createRequestObject = require("./createRequestObject"); +const createResponseObject = require("./createResponseObject"); + +// Render the Next.js page +const renderNextPage = (event) => { + // The Next.js page is rendered inside a promise that is resolved when the + // Next.js page ends the response via `res.end()` + const promise = new Promise((resolve) => { + // Create a Next.js-compatible request and response object + // These mock the ClientRequest and ServerResponse classes from node http + // See: https://nodejs.org/api/http.html + const req = createRequestObject({ event }); + const res = createResponseObject({ + onResEnd: (response) => resolve(response), + }); + + // Check if page is a Next.js page or an API route + const isNextPage = nextPage.render instanceof Function; + const isApiRoute = !isNextPage; + + // Perform the render: render() for Next.js page or default() for API route + if (isNextPage) return nextPage.render(req, res); + if (isApiRoute) return nextPage.default(req, res); + }); + + // Return the promise + return promise; +}; + +module.exports = renderNextPage; diff --git a/package-lock.json b/package-lock.json index 8ebe575..efe525c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17270,11 +17270,6 @@ } } }, - "next-aws-lambda": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/next-aws-lambda/-/next-aws-lambda-2.5.0.tgz", - "integrity": "sha512-TKI0e+RFOuevQnkliE73VCzx9VRF8qpXuL18uxOJvPhCe09pxLwfaDQMuNKf3b2d9PS/Ajn+Y/O41bBuJMu4aA==" - }, "next-tick": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", diff --git a/package.json b/package.json index 106147a..9cf5437 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,7 @@ "dependencies": { "@sls-next/lambda-at-edge": "^1.5.2", "commander": "^6.0.0", - "fs-extra": "^9.0.1", - "next-aws-lambda": "^2.5.0" + "fs-extra": "^9.0.1" }, "husky": { "hooks": {