From 96b7e40aa06dba721f0ac117b06d2b4625f87220 Mon Sep 17 00:00:00 2001 From: Afzal Sayed Date: Wed, 11 Nov 2020 17:20:13 +0530 Subject: [PATCH 1/6] Add test for res.redirect in API route Test that res.redirect works in API routes. Documentation: https://nextjs.org/docs/api-routes/response-helpers --- cypress/fixtures/pages/api/redirect.js | 6 ++++++ cypress/integration/default_spec.js | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 cypress/fixtures/pages/api/redirect.js diff --git a/cypress/fixtures/pages/api/redirect.js b/cypress/fixtures/pages/api/redirect.js new file mode 100644 index 0000000..3eb7923 --- /dev/null +++ b/cypress/fixtures/pages/api/redirect.js @@ -0,0 +1,6 @@ +export default async function preview(req, res) { + const { query } = req; + const { to } = query; + + res.redirect(`/redirectTest/${to}`); +} diff --git a/cypress/integration/default_spec.js b/cypress/integration/default_spec.js index 3f9180a..5162268 100644 --- a/cypress/integration/default_spec.js +++ b/cypress/integration/default_spec.js @@ -535,6 +535,12 @@ describe("Preview Mode", () => { cy.url().should("include", "/previewTest/999"); }); + it("redirects to test page with res.redirect", () => { + cy.visit("/api/redirect?to=999"); + + cy.url().should("include", "/redirectTest/999"); + }); + it("redirects to static preview test page", () => { cy.visit("/api/enterPreviewStatic"); From 97b59fd5c720fd7ec08e5f3aaefc0f01e40ebac6 Mon Sep 17 00:00:00 2001 From: Finn Woelm Date: Tue, 17 Nov 2020 13:06:55 +0800 Subject: [PATCH 2/6] Copy files from next-aws-lambda Copy over the files from the next-aws-lambda package and manually bundle them into our Netlify Functions. This gives us more flexibility to customize the compatibility layer between Netlify Functions and Next.js. For now, no changes have been made to the next-aws-lambda files and they have been copied as-is. next-aws-lambda source: https://github.com/serverless-nextjs/serverless-next.js/tree/master/packages/compat-layers/apigw-lambda-compat --- lib/config.js | 10 +- lib/helpers/setupNetlifyFunctionForPage.js | 14 +- lib/templates/compat.js | 22 +++ lib/templates/netlifyFunction.js | 2 +- lib/templates/reqResMapper.js | 159 +++++++++++++++++++++ package-lock.json | 5 - package.json | 3 +- 7 files changed, 201 insertions(+), 14 deletions(-) create mode 100644 lib/templates/compat.js create mode 100644 lib/templates/reqResMapper.js 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..3665859 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,6 +23,14 @@ const setupNetlifyFunctionForPage = ({ filePath, functionsPath }) => { errorOnExist: true, }); + // Copy function helpers + ["compat.js", "reqResMapper.js"].forEach((helper) => { + copySync(join(TEMPLATES_DIR, helper), join(functionDirectory, helper), { + overwrite: false, + errorOnExist: true, + }); + }); + // Copy page const nextPageCopyPath = join(functionDirectory, "nextJsPage.js"); copySync(join(NEXT_DIST_DIR, "serverless", filePath), nextPageCopyPath, { diff --git a/lib/templates/compat.js b/lib/templates/compat.js new file mode 100644 index 0000000..e87ba77 --- /dev/null +++ b/lib/templates/compat.js @@ -0,0 +1,22 @@ +// API Gateway Lambda Compat +// License: MIT +// Source: https://github.com/serverless-nextjs/serverless-next.js/blob/master/packages/compat-layers/apigw-lambda-compat/index.js + +const reqResMapper = require("./reqResMapper"); + +const handlerFactory = (page) => (event, _context, callback) => { + const { req, res, responsePromise } = reqResMapper(event, callback); + if (page.render instanceof Function) { + // Is a React component + page.render(req, res); + } else { + // Is an API + page.default(req, res); + } + + if (responsePromise) { + return responsePromise; + } +}; + +module.exports = handlerFactory; diff --git a/lib/templates/netlifyFunction.js b/lib/templates/netlifyFunction.js index 83e4799..28a9b59 100644 --- a/lib/templates/netlifyFunction.js +++ b/lib/templates/netlifyFunction.js @@ -2,7 +2,7 @@ // running next-on-netlify // Compatibility wrapper for NextJS page -const compat = require("next-aws-lambda"); +const compat = require("./compat"); // Load the NextJS page const page = require("./nextJsPage"); diff --git a/lib/templates/reqResMapper.js b/lib/templates/reqResMapper.js new file mode 100644 index 0000000..e8db99a --- /dev/null +++ b/lib/templates/reqResMapper.js @@ -0,0 +1,159 @@ +// API Gateway Lambda Compat +// License: MIT +// Source: https://github.com/serverless-nextjs/serverless-next.js/blob/master/packages/compat-layers/apigw-lambda-compat/lib/compatLayer.js + +const Stream = require("stream"); +const queryString = require("querystring"); +const http = require("http"); + +const reqResMapper = (event, callback) => { + const base64Support = process.env.BINARY_SUPPORT === "yes"; + const response = { + isBase64Encoded: base64Support, + multiValueHeaders: {}, + }; + let responsePromise; + + const newStream = new Stream.Readable(); + const req = Object.assign(newStream, http.IncomingMessage.prototype); + req.url = + (event.requestContext.path || event.path || "").replace( + new RegExp("^/" + event.requestContext.stage), + "" + ) || "/"; + + let qs = ""; + + if (event.multiValueQueryStringParameters) { + qs += queryString.stringify(event.multiValueQueryStringParameters); + } + + if (event.pathParameters) { + const pathParametersQs = queryString.stringify(event.pathParameters); + + if (qs.length > 0) { + qs += `&${pathParametersQs}`; + } else { + qs += pathParametersQs; + } + } + + const hasQueryString = qs.length > 0; + + if (hasQueryString) { + req.url += `?${qs}`; + } + + req.method = event.httpMethod; + req.rawHeaders = []; + req.headers = {}; + + const headers = event.multiValueHeaders || {}; + + for (const key of Object.keys(headers)) { + for (const value of headers[key]) { + req.rawHeaders.push(key); + req.rawHeaders.push(value); + } + req.headers[key.toLowerCase()] = headers[key].toString(); + } + + req.getHeader = (name) => { + return req.headers[name.toLowerCase()]; + }; + req.getHeaders = () => { + return req.headers; + }; + + req.connection = {}; + + 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); + }; + + const onResEnd = (callback, resolve) => (text) => { + if (text) res.write(text); + if (!res.statusCode) { + res.statusCode = 200; + } + + if (response.body) { + response.body = Buffer.from(response.body).toString( + base64Support ? "base64" : undefined + ); + } + response.multiValueHeaders = res.headers; + res.writeHead(response.statusCode); + fixApiGatewayMultipleHeaders(); + + if (callback) { + callback(null, response); + } else { + resolve(response); + } + }; + + if (typeof callback === "function") { + res.end = onResEnd(callback); + } else { + responsePromise = new Promise((resolve) => { + res.end = onResEnd(null, resolve); + }); + } + + if (event.body) { + req.push(event.body, event.isBase64Encoded ? "base64" : undefined); + } + + req.push(null); + + function fixApiGatewayMultipleHeaders() { + for (const key of Object.keys(response.multiValueHeaders)) { + if (!Array.isArray(response.multiValueHeaders[key])) { + response.multiValueHeaders[key] = [response.multiValueHeaders[key]]; + } + } + } + + return { req, res, responsePromise }; +}; + +module.exports = reqResMapper; 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": { From 9b0e645f5235ec2f465fb77c91d9209568cfc718 Mon Sep 17 00:00:00 2001 From: Finn Woelm Date: Tue, 17 Nov 2020 13:11:38 +0800 Subject: [PATCH 3/6] Remove workaround: base64 support in Netlify Functions As far as I know, Netlify Functions always support base64 encoding. So we can remove the code for checking if base64 encoding is supported. --- lib/templates/netlifyFunction.js | 7 ------- lib/templates/reqResMapper.js | 7 ++----- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/lib/templates/netlifyFunction.js b/lib/templates/netlifyFunction.js index 28a9b59..5022275 100644 --- a/lib/templates/netlifyFunction.js +++ b/lib/templates/netlifyFunction.js @@ -26,13 +26,6 @@ const callbackHandler = (callback) => }; 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"; - } - // 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")) { diff --git a/lib/templates/reqResMapper.js b/lib/templates/reqResMapper.js index e8db99a..ebc4d97 100644 --- a/lib/templates/reqResMapper.js +++ b/lib/templates/reqResMapper.js @@ -7,9 +7,8 @@ const queryString = require("querystring"); const http = require("http"); const reqResMapper = (event, callback) => { - const base64Support = process.env.BINARY_SUPPORT === "yes"; const response = { - isBase64Encoded: base64Support, + isBase64Encoded: true, multiValueHeaders: {}, }; let responsePromise; @@ -116,9 +115,7 @@ const reqResMapper = (event, callback) => { } if (response.body) { - response.body = Buffer.from(response.body).toString( - base64Support ? "base64" : undefined - ); + response.body = Buffer.from(response.body).toString("base64"); } response.multiValueHeaders = res.headers; res.writeHead(response.statusCode); From 8f3ad77b52679e653f82b785c87373f059f2b655 Mon Sep 17 00:00:00 2001 From: Finn Woelm Date: Tue, 17 Nov 2020 14:45:44 +0800 Subject: [PATCH 4/6] Netlify Function Handler: Use promise rather than callback Use the promise approach of next-aws-lambda rather than the callback approach, because it makes the code easier to read and puts it in the correct order of execution. --- lib/templates/netlifyFunction.js | 40 ++++++++++++-------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/lib/templates/netlifyFunction.js b/lib/templates/netlifyFunction.js index 5022275..c4cf912 100644 --- a/lib/templates/netlifyFunction.js +++ b/lib/templates/netlifyFunction.js @@ -6,26 +6,7 @@ const compat = require("./compat"); // 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) => { +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")) { @@ -37,15 +18,24 @@ exports.handler = (event, context, callback) => { console.log("[request]", path); // Render the page - compat(page)( + const response = await 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) + context ); + + // 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); }; From 224ad89b489b6be64317b37069d02ec641053452 Mon Sep 17 00:00:00 2001 From: Finn Woelm Date: Wed, 18 Nov 2020 11:03:43 +0800 Subject: [PATCH 5/6] Refactor: Replace compat.js with renderNextPage.js Wrap the logic for rendering the Next.js page into its own function. That keeps the netlifyFunction.js (function handler) minimal and easy to read. It will also make it easier for users to modify the function handler while keeping the render function unchanged (down the line, once we support this feature). --- lib/helpers/setupNetlifyFunctionForPage.js | 4 +-- lib/templates/compat.js | 22 ---------------- lib/templates/netlifyFunction.js | 21 ++++++---------- lib/templates/renderNextPage.js | 29 ++++++++++++++++++++++ 4 files changed, 39 insertions(+), 37 deletions(-) delete mode 100644 lib/templates/compat.js create mode 100644 lib/templates/renderNextPage.js diff --git a/lib/helpers/setupNetlifyFunctionForPage.js b/lib/helpers/setupNetlifyFunctionForPage.js index 3665859..67e3b3a 100644 --- a/lib/helpers/setupNetlifyFunctionForPage.js +++ b/lib/helpers/setupNetlifyFunctionForPage.js @@ -24,7 +24,7 @@ const setupNetlifyFunctionForPage = ({ filePath, functionsPath }) => { }); // Copy function helpers - ["compat.js", "reqResMapper.js"].forEach((helper) => { + ["renderNextPage.js", "reqResMapper.js"].forEach((helper) => { copySync(join(TEMPLATES_DIR, helper), join(functionDirectory, helper), { overwrite: false, errorOnExist: true, @@ -32,7 +32,7 @@ const setupNetlifyFunctionForPage = ({ filePath, functionsPath }) => { }); // 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/compat.js b/lib/templates/compat.js deleted file mode 100644 index e87ba77..0000000 --- a/lib/templates/compat.js +++ /dev/null @@ -1,22 +0,0 @@ -// API Gateway Lambda Compat -// License: MIT -// Source: https://github.com/serverless-nextjs/serverless-next.js/blob/master/packages/compat-layers/apigw-lambda-compat/index.js - -const reqResMapper = require("./reqResMapper"); - -const handlerFactory = (page) => (event, _context, callback) => { - const { req, res, responsePromise } = reqResMapper(event, callback); - if (page.render instanceof Function) { - // Is a React component - page.render(req, res); - } else { - // Is an API - page.default(req, res); - } - - if (responsePromise) { - return responsePromise; - } -}; - -module.exports = handlerFactory; diff --git a/lib/templates/netlifyFunction.js b/lib/templates/netlifyFunction.js index c4cf912..0fc926e 100644 --- a/lib/templates/netlifyFunction.js +++ b/lib/templates/netlifyFunction.js @@ -1,10 +1,8 @@ // TEMPLATE: This file will be copied to the Netlify functions directory when // running next-on-netlify -// Compatibility wrapper for NextJS page -const compat = require("./compat"); -// Load the NextJS page -const page = require("./nextJsPage"); +// 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 @@ -17,15 +15,12 @@ exports.handler = async (event, context, callback) => { const { path } = event; console.log("[request]", path); - // Render the page - const response = await compat(page)( - { - ...event, - // Required. Otherwise, compat() will complain - requestContext: {}, - }, - context - ); + // 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 diff --git a/lib/templates/renderNextPage.js b/lib/templates/renderNextPage.js new file mode 100644 index 0000000..32c4de9 --- /dev/null +++ b/lib/templates/renderNextPage.js @@ -0,0 +1,29 @@ +// Load the NextJS page +const nextPage = require("./nextPage"); +const reqResMapper = require("./reqResMapper"); + +// 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 callback = (_null, response) => resolve(response); + const { req, res } = reqResMapper(event, callback); + + // 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; From ef1e7aef648d8813440f9adb1e386c13202a897d Mon Sep 17 00:00:00 2001 From: Finn Woelm Date: Wed, 18 Nov 2020 11:55:17 +0800 Subject: [PATCH 6/6] Refactor: Split reqResMapper into createRequest/ResponseObject Split the reqResMapper function into two separate files. One for creating the request object and one for creating the response object. This is easier to read and understand. --- lib/helpers/setupNetlifyFunctionForPage.js | 7 +- lib/templates/createRequestObject.js | 81 +++++++++++ lib/templates/createResponseObject.js | 81 +++++++++++ lib/templates/renderNextPage.js | 9 +- lib/templates/reqResMapper.js | 156 --------------------- 5 files changed, 174 insertions(+), 160 deletions(-) create mode 100644 lib/templates/createRequestObject.js create mode 100644 lib/templates/createResponseObject.js delete mode 100644 lib/templates/reqResMapper.js diff --git a/lib/helpers/setupNetlifyFunctionForPage.js b/lib/helpers/setupNetlifyFunctionForPage.js index 67e3b3a..a5f4cb4 100644 --- a/lib/helpers/setupNetlifyFunctionForPage.js +++ b/lib/helpers/setupNetlifyFunctionForPage.js @@ -24,7 +24,12 @@ const setupNetlifyFunctionForPage = ({ filePath, functionsPath }) => { }); // Copy function helpers - ["renderNextPage.js", "reqResMapper.js"].forEach((helper) => { + const functionHelpers = [ + "renderNextPage.js", + "createRequestObject.js", + "createResponseObject.js", + ]; + functionHelpers.forEach((helper) => { copySync(join(TEMPLATES_DIR, helper), join(functionDirectory, helper), { 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/renderNextPage.js b/lib/templates/renderNextPage.js index 32c4de9..38d3f13 100644 --- a/lib/templates/renderNextPage.js +++ b/lib/templates/renderNextPage.js @@ -1,6 +1,7 @@ // Load the NextJS page const nextPage = require("./nextPage"); -const reqResMapper = require("./reqResMapper"); +const createRequestObject = require("./createRequestObject"); +const createResponseObject = require("./createResponseObject"); // Render the Next.js page const renderNextPage = (event) => { @@ -10,8 +11,10 @@ const renderNextPage = (event) => { // 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 callback = (_null, response) => resolve(response); - const { req, res } = reqResMapper(event, callback); + 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; diff --git a/lib/templates/reqResMapper.js b/lib/templates/reqResMapper.js deleted file mode 100644 index ebc4d97..0000000 --- a/lib/templates/reqResMapper.js +++ /dev/null @@ -1,156 +0,0 @@ -// API Gateway Lambda Compat -// License: MIT -// Source: https://github.com/serverless-nextjs/serverless-next.js/blob/master/packages/compat-layers/apigw-lambda-compat/lib/compatLayer.js - -const Stream = require("stream"); -const queryString = require("querystring"); -const http = require("http"); - -const reqResMapper = (event, callback) => { - const response = { - isBase64Encoded: true, - multiValueHeaders: {}, - }; - let responsePromise; - - const newStream = new Stream.Readable(); - const req = Object.assign(newStream, http.IncomingMessage.prototype); - req.url = - (event.requestContext.path || event.path || "").replace( - new RegExp("^/" + event.requestContext.stage), - "" - ) || "/"; - - let qs = ""; - - if (event.multiValueQueryStringParameters) { - qs += queryString.stringify(event.multiValueQueryStringParameters); - } - - if (event.pathParameters) { - const pathParametersQs = queryString.stringify(event.pathParameters); - - if (qs.length > 0) { - qs += `&${pathParametersQs}`; - } else { - qs += pathParametersQs; - } - } - - const hasQueryString = qs.length > 0; - - if (hasQueryString) { - req.url += `?${qs}`; - } - - req.method = event.httpMethod; - req.rawHeaders = []; - req.headers = {}; - - const headers = event.multiValueHeaders || {}; - - for (const key of Object.keys(headers)) { - for (const value of headers[key]) { - req.rawHeaders.push(key); - req.rawHeaders.push(value); - } - req.headers[key.toLowerCase()] = headers[key].toString(); - } - - req.getHeader = (name) => { - return req.headers[name.toLowerCase()]; - }; - req.getHeaders = () => { - return req.headers; - }; - - req.connection = {}; - - 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); - }; - - const onResEnd = (callback, resolve) => (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); - fixApiGatewayMultipleHeaders(); - - if (callback) { - callback(null, response); - } else { - resolve(response); - } - }; - - if (typeof callback === "function") { - res.end = onResEnd(callback); - } else { - responsePromise = new Promise((resolve) => { - res.end = onResEnd(null, resolve); - }); - } - - if (event.body) { - req.push(event.body, event.isBase64Encoded ? "base64" : undefined); - } - - req.push(null); - - function fixApiGatewayMultipleHeaders() { - for (const key of Object.keys(response.multiValueHeaders)) { - if (!Array.isArray(response.multiValueHeaders[key])) { - response.multiValueHeaders[key] = [response.multiValueHeaders[key]]; - } - } - } - - return { req, res, responsePromise }; -}; - -module.exports = reqResMapper;