Skip to content

Fix for searchParams #817

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

Merged
merged 6 commits into from
Apr 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/smart-ears-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@opennextjs/aws": patch
---

fix to not decode searchParams
fix multivalue query in searchParams for Node
17 changes: 7 additions & 10 deletions packages/open-next/src/core/routing/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,19 +196,16 @@ export function handleRewrites<T extends RewriteDefinition>(
);
// We need to use a localized path if the rewrite is not locale specific
const pathToUse = rewrite.locale === false ? rawPath : localizedRawPath;
// We need to encode the "+" character with its UTF-8 equivalent "%20" to avoid 2 issues:
// 1. The compile function from path-to-regexp will throw an error if it finds a "+" character.
// https://github.com/pillarjs/path-to-regexp?tab=readme-ov-file#unexpected--or-
// 2. The convertToQueryString function will replace the "+" character with %2B instead of %20.
// %2B does not get interpreted as a space in the URL thus breaking the query string.
const encodePlusQueryString = queryString.replaceAll("+", "%20");

debug("urlParts", { pathname, protocol, hostname, queryString });
const toDestinationPath = compile(escapeRegex(pathname));
const toDestinationPath = compile(escapeRegex(pathname, { isPath: true }));
const toDestinationHost = compile(escapeRegex(hostname));
const toDestinationQuery = compile(escapeRegex(encodePlusQueryString));
const toDestinationQuery = compile(escapeRegex(queryString));
const params = {
// params for the source
...getParamsFromSource(match(escapeRegex(rewrite.source)))(pathToUse),
...getParamsFromSource(
match(escapeRegex(rewrite.source, { isPath: true })),
)(pathToUse),
// params for the has
...rewrite.has?.reduce((acc, cur) => {
return Object.assign(acc, computeHas(cur));
Expand All @@ -219,7 +216,7 @@ export function handleRewrites<T extends RewriteDefinition>(
}, {}),
};
const isUsingParams = Object.keys(params).length > 0;
let rewrittenQuery = encodePlusQueryString;
let rewrittenQuery = queryString;
let rewrittenHost = hostname;
let rewrittenPath = pathname;
if (isUsingParams) {
Expand Down
24 changes: 9 additions & 15 deletions packages/open-next/src/core/routing/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,8 @@ import type { InternalEvent, InternalResult } from "types/open-next.js";
import { emptyReadableStream } from "utils/stream.js";

import { localizePath } from "./i18n/index.js";
//NOTE: we should try to avoid importing stuff from next as much as possible
// every release of next could break this
// const { run } = require("next/dist/server/web/sandbox");
// const { getCloneableBody } = require("next/dist/server/body-streams");
// const {
// signalFromNodeResponse,
// } = require("next/dist/server/web/spec-extension/adapters/next-request");
import {
convertBodyToReadableStream,
convertToQueryString,
getMiddlewareMatch,
isExternal,
} from "./util.js";
Expand All @@ -45,14 +37,16 @@ function defaultMiddlewareLoader() {
return import("./middleware.mjs");
}

// NOTE: As of Nextjs 13.4.13+, the middleware is handled outside the next-server.
// OpenNext will run the middleware in a sandbox and set the appropriate req headers
// and res.body prior to processing the next-server.
// @returns undefined | res.end()

// if res.end() is return, the parent needs to return and not process next server
/**
*
* @param internalEvent the internal event
* @param initialSearch the initial query string as it was received in the handler
* @param middlewareLoader Only used for unit test
* @returns `Promise<MiddlewareEvent | InternalResult>`
*/
export async function handleMiddleware(
internalEvent: InternalEvent,
initialSearch: string,
middlewareLoader: MiddlewareLoader = defaultMiddlewareLoader,
): Promise<MiddlewareEvent | InternalResult> {
const headers = internalEvent.headers;
Expand All @@ -73,7 +67,7 @@ export async function handleMiddleware(
if (!hasMatch) return internalEvent;

const initialUrl = new URL(normalizedPath, internalEvent.url);
initialUrl.search = convertToQueryString(internalEvent.query);
initialUrl.search = initialSearch;
const url = initialUrl.href;

const middleware = await middlewareLoader();
Expand Down
38 changes: 19 additions & 19 deletions packages/open-next/src/core/routing/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Readable } from "node:stream";
import { BuildId, HtmlPages, NextConfig } from "config/index.js";
import type { IncomingMessage } from "http/index.js";
import { OpenNextNodeResponse } from "http/openNextResponse.js";
import { parseHeaders } from "http/util.js";
import { getQueryFromIterator, parseHeaders } from "http/util.js";
import type {
FunctionsConfigManifest,
MiddlewareManifest,
Expand Down Expand Up @@ -38,13 +38,11 @@ export function isExternal(url?: string, host?: string) {
export function convertFromQueryString(query: string) {
if (query === "") return {};
const queryParts = query.split("&");
return queryParts.reduce(
(acc, part) => {
const [key, value] = part.split("=");
acc[key] = value;
return acc;
},
{} as Record<string, string>,
return getQueryFromIterator(
queryParts.map((p) => {
const [key, value] = p.split("=");
return [key, value] as const;
}),
);
}

Expand Down Expand Up @@ -122,23 +120,20 @@ export function convertRes(res: OpenNextNodeResponse): InternalResult {
* Make sure that multi-value query parameters are transformed to
* ?key=value1&key=value2&... so that Next converts those parameters
* to an array when reading the query parameters
* query should be properly encoded before using this function
* @__PURE__
*/
export function convertToQueryString(query: Record<string, string | string[]>) {
// URLSearchParams is a representation of the PARSED query.
// So we must decode the value before appending it to the URLSearchParams.
// https://stackoverflow.com/a/45516812
const urlQuery = new URLSearchParams();
const queryStrings: string[] = [];
Object.entries(query).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((entry) => urlQuery.append(key, decodeURIComponent(entry)));
value.forEach((entry) => queryStrings.push(`${key}=${entry}`));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use URLSearchParams here without the decodeURIComponent?

Encoding the search parameters manually seems error-prone

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ho right i'll revert this one, that was an attempt to fix it, but this function wasn't the only issue here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do remember now why i did that, convertToQueryString is only used for already properly encoded search parameters.
The query in these cases are provided by the converter (which in the case of cloudflare already use URLSearchParams)

If we revert this without the decodeURIComponent it will break the handleRewrite from next config (Specifically this test

const result = handleRedirects(event, [
{
source: "/foo",
destination: "/search?bar=hello+world&baz=new%2C+earth",
locale: false,
statusCode: 308,
regex: "^(?!/_next)/foo(?:/)?$",
},
]);
)

It should be safe enough to use it here as long as we don't use it in places where query could be not properly encoded.

} else {
urlQuery.append(key, decodeURIComponent(value));
queryStrings.push(`${key}=${value}`);
}
});
const queryString = urlQuery.toString();

return queryString ? `?${queryString}` : "";
return queryStrings.length > 0 ? `?${queryStrings.join("&")}` : "";
}

/**
Expand Down Expand Up @@ -182,11 +177,15 @@ export function getMiddlewareMatch(
*
* @__PURE__
*/
export function escapeRegex(str: string) {
return str
export function escapeRegex(
str: string,
{ isPath }: { isPath?: boolean } = {},
) {
const result = str
.replaceAll("(.)", "_µ1_")
.replaceAll("(..)", "_µ2_")
.replaceAll("(...)", "_µ3_");
return isPath ? result : result.replaceAll("+", "_µ4_");
}

/**
Expand All @@ -197,7 +196,8 @@ export function unescapeRegex(str: string) {
return str
.replaceAll("_µ1_", "(.)")
.replaceAll("_µ2_", "(..)")
.replaceAll("_µ3_", "(...)");
.replaceAll("_µ3_", "(...)")
.replaceAll("_µ4_", "+");
}

/**
Expand Down
8 changes: 7 additions & 1 deletion packages/open-next/src/core/routingHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,13 @@ export default async function routingHandler(
return redirect;
}

const eventOrResult = await handleMiddleware(internalEvent);
const eventOrResult = await handleMiddleware(
internalEvent,
// We need to pass the initial search without any decoding
// TODO: we'd need to refactor InternalEvent to include the initial querystring directly
// Should be done in another PR because it is a breaking change
new URL(event.url).search,
);
const isResult = "statusCode" in eventOrResult;
if (isResult) {
return eventOrResult;
Expand Down
22 changes: 22 additions & 0 deletions packages/open-next/src/http/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,25 @@ export function parseCookies(
? cookies.split(/(?<!Expires=\w+),/i).map((c) => c.trim())
: cookies;
}

/**
*
* Get the query object from an iterable of [key, value] pairs
* @param it - The iterable of [key, value] pairs
* @returns The query object
*/
export function getQueryFromIterator(it: Iterable<[string, string]>) {
const query: Record<string, string | string[]> = {};
for (const [key, value] of it) {
if (key in query) {
if (Array.isArray(query[key])) {
query[key].push(value);
} else {
query[key] = [query[key], value];
}
} else {
query[key] = value;
}
}
return query;
}
14 changes: 2 additions & 12 deletions packages/open-next/src/overrides/converters/edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
MiddlewareResult,
} from "types/open-next";
import type { Converter } from "types/overrides";
import { getQueryFromSearchParams } from "./utils.js";

declare global {
// Makes convertTo returns the request instead of fetching it.
Expand All @@ -18,18 +19,7 @@ const converter: Converter<InternalEvent, InternalResult | MiddlewareResult> = {
const url = new URL(event.url);

const searchParams = url.searchParams;
const query: Record<string, string | string[]> = {};
for (const [key, value] of searchParams.entries()) {
if (key in query) {
if (Array.isArray(query[key])) {
query[key].push(value);
} else {
query[key] = [query[key], value];
}
} else {
query[key] = value;
}
}
const query = getQueryFromSearchParams(searchParams);
// Transform body into Buffer
const body = await event.arrayBuffer();
const headers: Record<string, string> = {};
Expand Down
4 changes: 2 additions & 2 deletions packages/open-next/src/overrides/converters/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { IncomingMessage } from "node:http";
import { parseCookies } from "http/util";
import type { InternalResult } from "types/open-next";
import type { Converter } from "types/overrides";
import { extractHostFromHeaders } from "./utils";
import { extractHostFromHeaders, getQueryFromSearchParams } from "./utils.js";

const converter: Converter = {
convertFrom: async (req: IncomingMessage) => {
Expand All @@ -26,7 +26,7 @@ const converter: Converter = {
.filter(([key]) => key),
);
const url = new URL(req.url!, `http://${extractHostFromHeaders(headers)}`);
const query = Object.fromEntries(url.searchParams.entries());
const query = getQueryFromSearchParams(url.searchParams);
return {
type: "core",
method: req.method ?? "GET",
Expand Down
12 changes: 12 additions & 0 deletions packages/open-next/src/overrides/converters/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getQueryFromIterator } from "http/util.js";

export function removeUndefinedFromQuery(
query: Record<string, string | string[] | undefined>,
) {
Expand All @@ -21,3 +23,13 @@ export function extractHostFromHeaders(
): string {
return headers["x-forwarded-host"] ?? headers.host ?? "on";
}

/**
* Get the query object from an URLSearchParams
*
* @param searchParams
* @returns
*/
export function getQueryFromSearchParams(searchParams: URLSearchParams) {
return getQueryFromIterator(searchParams.entries());
}
Loading
Loading