From 01ff466c9cba78ba741119a424dc0486cb90b475 Mon Sep 17 00:00:00 2001 From: Chalo Salvador Date: Mon, 26 Jan 2026 16:18:31 -0500 Subject: [PATCH 1/3] Add external URL rewrite handling in Next.js integration - Introduced caching for external rewrites from the routes manifest. - Implemented functions to load and handle external URL rewrites, ensuring proper proxying in Cloud Functions. - Enhanced request handling to support external rewrites before processing by Next.js. --- .../firebase-frameworks/src/next.js/index.ts | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/packages/firebase-frameworks/src/next.js/index.ts b/packages/firebase-frameworks/src/next.js/index.ts index 4a389a1a0..26036cdff 100644 --- a/packages/firebase-frameworks/src/next.js/index.ts +++ b/packages/firebase-frameworks/src/next.js/index.ts @@ -1,4 +1,6 @@ import { parse } from "url"; +import { join } from "node:path"; +import { readFile } from "node:fs/promises"; import createNextServer from "next"; import type { Request } from "firebase-functions/v2/https"; @@ -15,7 +17,172 @@ const nextApp: NextServer = createNextServer({ port: 8080, }); +interface RoutesManifestRewrite { + source: string; + destination: string; + regex: string; + has?: unknown[]; + missing?: unknown[]; +} + +interface RoutesManifest { + rewrites: + | RoutesManifestRewrite[] + | { + beforeFiles?: RoutesManifestRewrite[]; + afterFiles?: RoutesManifestRewrite[]; + fallback?: RoutesManifestRewrite[]; + }; +} + +// Cache for external rewrites +let externalRewritesCache: RoutesManifestRewrite[] | null = null; + +/** + * Load and cache external rewrites from the routes manifest. + * External rewrites are those with http:// or https:// destinations. + */ +async function getExternalRewrites(): Promise { + if (externalRewritesCache !== null) { + return externalRewritesCache; + } + + try { + const manifestPath = join(process.cwd(), ".next", "routes-manifest.json"); + const manifestContent = await readFile(manifestPath, "utf-8"); + const manifest: RoutesManifest = JSON.parse(manifestContent); + + let allRewrites: RoutesManifestRewrite[] = []; + if (Array.isArray(manifest.rewrites)) { + allRewrites = manifest.rewrites; + } else if (manifest.rewrites) { + allRewrites = [ + ...(manifest.rewrites.beforeFiles || []), + ...(manifest.rewrites.afterFiles || []), + ...(manifest.rewrites.fallback || []), + ]; + } + + // Filter to only external URL rewrites + externalRewritesCache = allRewrites.filter( + (rewrite) => + rewrite.destination.startsWith("http://") || + rewrite.destination.startsWith("https://") + ); + + return externalRewritesCache; + } catch { + externalRewritesCache = []; + return externalRewritesCache; + } +} + +/** + * Handle external URL rewrites by proxying directly. + * This is needed because Next.js's internal http-proxy doesn't work + * properly in Cloud Functions due to socket handling issues. + * + * Returns true if the request was handled, false otherwise. + */ +async function handleExternalRewrite( + req: Request, + res: Response +): Promise { + const externalRewrites = await getExternalRewrites(); + if (externalRewrites.length === 0) { + return false; + } + + const url = req.url || "/"; + + for (const rewrite of externalRewrites) { + const regex = new RegExp(rewrite.regex); + const match = url.match(regex); + + if (match) { + try { + // Build the destination URL by replacing path parameters + let destination = rewrite.destination; + + // Handle :param* style path parameters using capture groups + if (match.length > 1) { + // Replace :path* or similar with the captured group + const sourceParams = rewrite.source.match(/:([^/]+)\*?/g) || []; + for (let i = 0; i < sourceParams.length && i + 1 < match.length; i++) { + const capturedValue = match[i + 1] || ""; + destination = destination.replace(sourceParams[i], capturedValue); + } + } + + // Make the proxy request + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (key.toLowerCase() !== "host" && typeof value === "string") { + headers[key] = value; + } + } + headers["x-forwarded-host"] = req.headers.host || ""; + + const fetchOptions: RequestInit = { + method: req.method, + headers, + redirect: "manual", + }; + + // Add body for non-GET/HEAD requests + if ( + req.method && + !["GET", "HEAD"].includes(req.method) && + req.rawBody + ) { + // Convert Buffer to Uint8Array for fetch compatibility + fetchOptions.body = new Uint8Array(req.rawBody); + } + + const proxyRes = await fetch(destination, fetchOptions); + + // Copy response headers, excluding problematic ones + // - transfer-encoding: handled by the framework + // - content-encoding: fetch auto-decompresses + // - content-length: may change after decompression + const skipHeaders = [ + "transfer-encoding", + "content-encoding", + "content-length", + ]; + for (const [key, value] of proxyRes.headers.entries()) { + if (!skipHeaders.includes(key.toLowerCase())) { + res.setHeader(key, value); + } + } + + res.status(proxyRes.status); + + // Get response as buffer and send + const buffer = Buffer.from(await proxyRes.arrayBuffer()); + res.send(buffer); + + return true; + } catch (err) { + console.error("External rewrite proxy error:", err); + res.status(502).send("Bad Gateway: Failed to proxy to external URL"); + return true; + } + } + } + + return false; +} + export const handle = async (req: Request, res: Response): Promise => { + // Handle external URL rewrites first, before Next.js processes the request. + // This is necessary because Next.js's internal http-proxy doesn't work + // properly in Cloud Functions due to socket handling issues. + const handled = await handleExternalRewrite(req, res); + if (handled) { + return; + } + await nextApp.prepare(); const parsedUrl = parse(req.url, true); const incomingMessage = incomingMessageFromExpress(req); From 1639e1fbe5c155a1e0e0eb40fd501343497eb791 Mon Sep 17 00:00:00 2001 From: Chalo Salvador Date: Mon, 26 Jan 2026 16:30:57 -0500 Subject: [PATCH 2/3] Improve error handling in getExternalRewrites function --- packages/firebase-frameworks/src/next.js/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/firebase-frameworks/src/next.js/index.ts b/packages/firebase-frameworks/src/next.js/index.ts index 26036cdff..8b8819d1d 100644 --- a/packages/firebase-frameworks/src/next.js/index.ts +++ b/packages/firebase-frameworks/src/next.js/index.ts @@ -71,7 +71,8 @@ async function getExternalRewrites(): Promise { ); return externalRewritesCache; - } catch { + } catch (err) { + console.error("Failed to load or parse routes-manifest.json:", err); externalRewritesCache = []; return externalRewritesCache; } From e67d9c73f2aab1fb7e917cf662ec624ff3b7fd77 Mon Sep 17 00:00:00 2001 From: Chalo Salvador Date: Mon, 26 Jan 2026 16:49:13 -0500 Subject: [PATCH 3/3] Formatting and useing Set for skipHeaders --- .../firebase-frameworks/src/next.js/index.ts | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/packages/firebase-frameworks/src/next.js/index.ts b/packages/firebase-frameworks/src/next.js/index.ts index 8b8819d1d..035406c7e 100644 --- a/packages/firebase-frameworks/src/next.js/index.ts +++ b/packages/firebase-frameworks/src/next.js/index.ts @@ -66,8 +66,7 @@ async function getExternalRewrites(): Promise { // Filter to only external URL rewrites externalRewritesCache = allRewrites.filter( (rewrite) => - rewrite.destination.startsWith("http://") || - rewrite.destination.startsWith("https://") + rewrite.destination.startsWith("http://") || rewrite.destination.startsWith("https://"), ); return externalRewritesCache; @@ -85,10 +84,7 @@ async function getExternalRewrites(): Promise { * * Returns true if the request was handled, false otherwise. */ -async function handleExternalRewrite( - req: Request, - res: Response -): Promise { +async function handleExternalRewrite(req: Request, res: Response): Promise { const externalRewrites = await getExternalRewrites(); if (externalRewrites.length === 0) { return false; @@ -131,13 +127,9 @@ async function handleExternalRewrite( }; // Add body for non-GET/HEAD requests - if ( - req.method && - !["GET", "HEAD"].includes(req.method) && - req.rawBody - ) { - // Convert Buffer to Uint8Array for fetch compatibility - fetchOptions.body = new Uint8Array(req.rawBody); + if (req.method && !["GET", "HEAD"].includes(req.method) && req.rawBody) { + // Node.js fetch accepts Buffer directly at runtime + fetchOptions.body = req.rawBody as unknown as BodyInit; } const proxyRes = await fetch(destination, fetchOptions); @@ -146,13 +138,9 @@ async function handleExternalRewrite( // - transfer-encoding: handled by the framework // - content-encoding: fetch auto-decompresses // - content-length: may change after decompression - const skipHeaders = [ - "transfer-encoding", - "content-encoding", - "content-length", - ]; + const skipHeaders = new Set(["transfer-encoding", "content-encoding", "content-length"]); for (const [key, value] of proxyRes.headers.entries()) { - if (!skipHeaders.includes(key.toLowerCase())) { + if (!skipHeaders.has(key.toLowerCase())) { res.setHeader(key, value); } }