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

Proxy readable rawBody on request for Next.js #116

Closed
wants to merge 2 commits into from

Conversation

mfellner
Copy link
Contributor

First of all, thanks for supporting Next.js on Firebase at all. It's great!

For Server Action POST requests and other features that depend on the request body, Next.js needs to consume the raw request as a readable stream.

However, this is not possible in Cloud Functions because the stream was already consumed and the request payload stored in the body and rawBody properties (afaik). The Next.js logic and code (especially for server actions) is so convoluted that there's no real hope of getting it to support the rawBody property.

On the other hand, people have asked for a way to turn off body parsing in Cloud Functions since at least 2017 and so far the only outcome has been the rawBody property.

As a workaround we can proxy all the relevant Readable methods to a Readable instance of the rawBody instead. This seems to work but is obviously a major hack.

Anyway, I was hoping to kick off some discussion around this topic with my pull request! 😃

(relates to firebase/firebase-tools#6468)

@gustavopch
Copy link

gustavopch commented Nov 16, 2023

I have the same problem with Remix and had to patch it like this, but I'm afraid this will not allow streaming responses to work if I try to use them:

diff --git a/node_modules/@remix-run/express/dist/server.js b/node_modules/@remix-run/express/dist/server.js
index c827141..8fd2a36 100644
--- a/node_modules/@remix-run/express/dist/server.js
+++ b/node_modules/@remix-run/express/dist/server.js
@@ -82,7 +83,7 @@ function createRemixRequest(req, res) {
     signal: controller.signal
   };
   if (req.method !== "GET" && req.method !== "HEAD") {
-    init.body = node.createReadableStreamFromReadable(req);
+    init.body = req.rawBody ?? node.createReadableStreamFromReadable(req);
     init.duplex = "half";
   }
   return new Request(url.href, init);

I hope your PR can also be useful for Remix. 🤞

@mfellner
Copy link
Contributor Author

mfellner commented Nov 16, 2023

Unfortunately my suggested patch alone doesn't fix the problem completely. For some reason it's still necessary to patch Next.js like this:

diff --git a/node_modules/next/dist/server/base-http/node.js b/node_modules/next/dist/server/base-http/node.js
index a2e6c07..8949856 100644
--- a/node_modules/next/dist/server/base-http/node.js
+++ b/node_modules/next/dist/server/base-http/node.js
@@ -23,6 +23,7 @@ _export(exports, {
 const _apiutils = require("../api-utils");
 const _requestmeta = require("../request-meta");
 const _index = require("./index");
+const { Readable } = require("stream");
 let _NEXT_REQUEST_META = _requestmeta.NEXT_REQUEST_META;
 class NodeNextRequest extends _index.BaseNextRequest {
     get originalRequest() {
@@ -37,7 +38,7 @@ class NodeNextRequest extends _index.BaseNextRequest {
         this._req = value;
     }
     constructor(_req){
-        super(_req.method.toUpperCase(), _req.url, _req);
+        super(_req.method.toUpperCase(), _req.url, _req.rawBody instanceof Buffer ? Readable.from(_req.rawBody): _req);
         this._req = _req;
         this.headers = this._req.headers;
         this[_NEXT_REQUEST_META] = this._req[_requestmeta.NEXT_REQUEST_META] || {};

Otherwise an exception like this can occur (when submitting a POST form request):

Error: Unexpected end of form
    at e.exports._final (/workspace/node_modules/next/dist/compiled/next-server/app-page.runtime.prod.js:2:472)
    at callFinal (node:internal/streams/writable:757:12)
    at prefinish (node:internal/streams/writable:769:7)
    at finishMaybe (node:internal/streams/writable:779:5)
    at Writable.end (node:internal/streams/writable:687:5)
    at onend (node:internal/streams/readable:756:10)
    at process.processTicksAndRejections (node:internal/process/task_queues:77:11)

Next.js uses some precompiled code in production (presumable for the Vercel edge runtime) so it's quite difficult to figure out how all the code depends on each other.

I'm afraid there may not be an easy fix here after all!

@jamesdaniels
Copy link
Collaborator

@leoortizz would you mind take a peek at this solution?

@leoortizz
Copy link
Contributor

@mfellner Thanks for the initiative! Unfortunately we can't patch Next.js.

Can you share how you were testing this fix?

@mfellner
Copy link
Contributor Author

mfellner commented Nov 25, 2023

Hey @leoortizz, sure. Basically, I'm applying 3 patches on my side:

Patch for Next.js to use a Readable of rawBody instead of the request itself as the 3rd constructor argument of NodeNextRequest.

I couldn't figure out why this is still needed after the main patch in firebase-frameworks, but it is. Perhaps somewhere there is an unpatched request still being forwarded to Next.js?

next+14.0.3.patch
diff --git a/node_modules/next/dist/server/base-http/node.js b/node_modules/next/dist/server/base-http/node.js
index a2e6c07..8949856 100644
--- a/node_modules/next/dist/server/base-http/node.js
+++ b/node_modules/next/dist/server/base-http/node.js
@@ -23,6 +23,7 @@ _export(exports, {
 const _apiutils = require("../api-utils");
 const _requestmeta = require("../request-meta");
 const _index = require("./index");
+const { Readable } = require("stream");
 let _NEXT_REQUEST_META = _requestmeta.NEXT_REQUEST_META;
 class NodeNextRequest extends _index.BaseNextRequest {
     get originalRequest() {
@@ -37,7 +38,7 @@ class NodeNextRequest extends _index.BaseNextRequest {
         this._req = value;
     }
     constructor(_req){
-        super(_req.method.toUpperCase(), _req.url, _req);
+        super(_req.method.toUpperCase(), _req.url, _req.rawBody instanceof Buffer ? Readable.from(_req.rawBody): _req);
         this._req = _req;
         this.headers = this._req.headers;
         this[_NEXT_REQUEST_META] = this._req[_requestmeta.NEXT_REQUEST_META] || {};

The main "hacky" patch for firebase-frameworks to essentially proxy all the Readable methods to the rawBody stream. This allows Next.js to read the request object as a stream, which is what it expects, but pulls all the data from the rawBody instead.

firebase-frameworks+0.10.6.patch
diff --git a/node_modules/firebase-frameworks/dist/next.js/index.js b/node_modules/firebase-frameworks/dist/next.js/index.js
index 87a1714..a1e3628 100644
--- a/node_modules/firebase-frameworks/dist/next.js/index.js
+++ b/node_modules/firebase-frameworks/dist/next.js/index.js
@@ -1,6 +1,7 @@
 import { parse } from "url";
 import { default as next } from "next";
 import LRU from "lru-cache";
+import { Readable } from "stream";
 const nextAppsLRU = new LRU({
     // TODO tune this
     max: 3,
@@ -29,5 +30,49 @@ export const handle = async (req, res) => {
     }
     await nextApp.prepare();
     const parsedUrl = parse(url, true);
-    nextApp.getRequestHandler()(req, res, parsedUrl);
+    console.error("FIREBASE FRAMEWORKS REQ.BODY", typeof req.body, req.body);
+    console.error("FIREBASE FRAMEWORKS REQ.RAWBODY", typeof req.rawBody, req.rawBody);
+    if (req.rawBody instanceof Buffer) {
+        // Proxy all the Readable methods to the rawBody stream.
+        const rawBodyReadable = Readable.from(req.rawBody);
+        const reqProxy = new Proxy(req, {
+            get(target, prop) {
+              if (
+                prop === "read" ||
+                prop === "write" ||
+                prop === "pipe" ||
+                prop === "on" ||
+                prop === "closed" ||
+                prop === "setHeader" ||
+                prop === "writable" ||
+                prop === "req" ||
+                prop === "destroy" ||
+                prop === "destroyed" ||
+                prop === "push" ||
+                prop === "emit" ||
+                prop === "domain" ||
+                prop === "writableErrored" ||
+                prop === "readableErrored" ||
+                prop === "_read" ||
+                prop === "_events" ||
+                prop === "_eventsCount" ||
+                prop === "_readableState" ||
+                prop === "_readableState" ||
+                prop === "_writableState" ||
+                prop === "_destroy" ||
+                prop === Symbol.asyncIterator ||
+                prop === Symbol.asyncDispose ||
+                (typeof prop === "symbol" &&
+                  prop.toString() === "Symbol(nodejs.stream.writable)")
+              ) {
+                return Reflect.get(rawBodyReadable, prop);
+              } else {
+                return Reflect.get(target, prop);
+              }
+            },
+        });
+        nextApp.getRequestHandler()(reqProxy, res, parsedUrl);
+    } else {
+        nextApp.getRequestHandler()(req, res, parsedUrl);
+    }
 };

Finally another patch for firebase-tools to make it copy the patches folder and run patch-package as a postinstall script.

firebase-tools+12.9.1.patch
diff --git a/node_modules/firebase-tools/lib/frameworks/index.js b/node_modules/firebase-tools/lib/frameworks/index.js
index 3e14291..044f7df 100644
--- a/node_modules/firebase-tools/lib/frameworks/index.js
+++ b/node_modules/firebase-tools/lib/frameworks/index.js
@@ -310,7 +310,9 @@ async function prepareFrameworks(purpose, targetNames, context, options, emulato
                 (0, utils_2.logWarning)(`This integration expects Node version ${(0, utils_1.conjoinOptions)(constants_2.VALID_ENGINES.node, "or")}. You're running version ${constants_2.NODE_VERSION}, problems may be encountered.`);
             }
             (_k = packageJson.engines).node || (_k.node = engine.toString());
-            delete packageJson.scripts;
+            const originalPackageJsonScripts = packageJson.scripts;
+            packageJson.scripts = {};
+            packageJson.scripts.postinstall = originalPackageJsonScripts?.postinstall;
             delete packageJson.devDependencies;
             const bundledDependencies = packageJson.bundledDependencies || {};
             if (Object.keys(bundledDependencies).length) {
@@ -345,6 +347,12 @@ async function prepareFrameworks(purpose, targetNames, context, options, emulato
             await (0, promises_1.writeFile)((0, path_1.join)(functionsDist, "package.json"), JSON.stringify(packageJson, null, 2));
             await (0, promises_1.copyFile)(getProjectPath("package-lock.json"), (0, path_1.join)(functionsDist, "package-lock.json")).catch(() => {
             });
+            const patches = await new Promise((resolve, reject) => glob(getProjectPath("patches/*"), (err, matches) => {
+                if (err) reject(err);
+                else resolve(matches);
+            }));
+            await (0, fs_extra_1.mkdirp)((0, path_1.join)(functionsDist, "patches"));
+            await Promise.all(patches.map((path) => (0, promises_1.copyFile)(path, (0, path_1.join)(functionsDist, "patches", (0, path_1.basename)(path)))));
             if (await (0, fs_extra_1.pathExists)(getProjectPath(".npmrc"))) {
                 await (0, promises_1.copyFile)(getProjectPath(".npmrc"), (0, path_1.join)(functionsDist, ".npmrc"));
             }

While this setup works well enough for me, I don't think it's a good long-term solution and probably not suitable for production.

Given that the Next.js patch is still needed, I also don't know how to make it all work by only changing firebase-frameworks.

However, this approach still demonstrates the underlying issue and how to fix it in principle. Next.js wants to read the request body as a stream from the request object but fails to do so from the Cloud Functions request object.

Perhaps the are some clues in the Open Next project which tries to make Next.js work with AWS Lambda. I haven't looked into it yet.

As previously discussed on the issue tacker, there are often use cases where it's necessary to have access to the original, unmodified request object. This is especially true for frameworks like Next.js which expect to be able to read the request body as a stream.

Maybe there is also some better approach of creating a new request wrapper class which can be used as an adapter for the Next.js server.

Such a solution would probably be more robust and easier to maintain than the current patching approach.

In terms of testing, I was simply deploying a basic Next.js app with server actions. The POST request of the server action simply times out without the patch.

@jamesdaniels
Copy link
Collaborator

Closing in favor of #122, thanks for finding the root cause.

jamesdaniels added a commit that referenced this pull request Nov 30, 2023
As identified in #116 the root cause of the problems with non-get requests in NextJS on Firebase Hosting is that the ExpressJS' readable has been spent on the body-parser middleware. Solution is to create a new Readable from rawBody and pass that to the NextJS request handler.

* Proxy express requests through a new IncomingMessage
* Drop the LRU cache of next apps, no longer needed

---------

Co-authored-by: Leonardo Ortiz <[email protected]>
Co-authored-by: Maximilian Fellner <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants