Skip to content

Commit be3653a

Browse files
authored
Fix for searchParams (#817)
* fix multivalue query in searchParams for node * fix use searchParams original encoding * changeset * fix middleware not using the initial query * fix convertFromQueryString * review
1 parent 997b392 commit be3653a

File tree

11 files changed

+138
-76
lines changed

11 files changed

+138
-76
lines changed

.changeset/smart-ears-begin.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@opennextjs/aws": patch
3+
---
4+
5+
fix to not decode searchParams
6+
fix multivalue query in searchParams for Node

packages/open-next/src/core/routing/matcher.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -196,19 +196,16 @@ export function handleRewrites<T extends RewriteDefinition>(
196196
);
197197
// We need to use a localized path if the rewrite is not locale specific
198198
const pathToUse = rewrite.locale === false ? rawPath : localizedRawPath;
199-
// We need to encode the "+" character with its UTF-8 equivalent "%20" to avoid 2 issues:
200-
// 1. The compile function from path-to-regexp will throw an error if it finds a "+" character.
201-
// https://github.com/pillarjs/path-to-regexp?tab=readme-ov-file#unexpected--or-
202-
// 2. The convertToQueryString function will replace the "+" character with %2B instead of %20.
203-
// %2B does not get interpreted as a space in the URL thus breaking the query string.
204-
const encodePlusQueryString = queryString.replaceAll("+", "%20");
199+
205200
debug("urlParts", { pathname, protocol, hostname, queryString });
206-
const toDestinationPath = compile(escapeRegex(pathname));
201+
const toDestinationPath = compile(escapeRegex(pathname, { isPath: true }));
207202
const toDestinationHost = compile(escapeRegex(hostname));
208-
const toDestinationQuery = compile(escapeRegex(encodePlusQueryString));
203+
const toDestinationQuery = compile(escapeRegex(queryString));
209204
const params = {
210205
// params for the source
211-
...getParamsFromSource(match(escapeRegex(rewrite.source)))(pathToUse),
206+
...getParamsFromSource(
207+
match(escapeRegex(rewrite.source, { isPath: true })),
208+
)(pathToUse),
212209
// params for the has
213210
...rewrite.has?.reduce((acc, cur) => {
214211
return Object.assign(acc, computeHas(cur));
@@ -219,7 +216,7 @@ export function handleRewrites<T extends RewriteDefinition>(
219216
}, {}),
220217
};
221218
const isUsingParams = Object.keys(params).length > 0;
222-
let rewrittenQuery = encodePlusQueryString;
219+
let rewrittenQuery = queryString;
223220
let rewrittenHost = hostname;
224221
let rewrittenPath = pathname;
225222
if (isUsingParams) {

packages/open-next/src/core/routing/middleware.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,8 @@ import type { InternalEvent, InternalResult } from "types/open-next.js";
1010
import { emptyReadableStream } from "utils/stream.js";
1111

1212
import { localizePath } from "./i18n/index.js";
13-
//NOTE: we should try to avoid importing stuff from next as much as possible
14-
// every release of next could break this
15-
// const { run } = require("next/dist/server/web/sandbox");
16-
// const { getCloneableBody } = require("next/dist/server/body-streams");
17-
// const {
18-
// signalFromNodeResponse,
19-
// } = require("next/dist/server/web/spec-extension/adapters/next-request");
2013
import {
2114
convertBodyToReadableStream,
22-
convertToQueryString,
2315
getMiddlewareMatch,
2416
isExternal,
2517
} from "./util.js";
@@ -45,14 +37,16 @@ function defaultMiddlewareLoader() {
4537
return import("./middleware.mjs");
4638
}
4739

48-
// NOTE: As of Nextjs 13.4.13+, the middleware is handled outside the next-server.
49-
// OpenNext will run the middleware in a sandbox and set the appropriate req headers
50-
// and res.body prior to processing the next-server.
51-
// @returns undefined | res.end()
52-
53-
// if res.end() is return, the parent needs to return and not process next server
40+
/**
41+
*
42+
* @param internalEvent the internal event
43+
* @param initialSearch the initial query string as it was received in the handler
44+
* @param middlewareLoader Only used for unit test
45+
* @returns `Promise<MiddlewareEvent | InternalResult>`
46+
*/
5447
export async function handleMiddleware(
5548
internalEvent: InternalEvent,
49+
initialSearch: string,
5650
middlewareLoader: MiddlewareLoader = defaultMiddlewareLoader,
5751
): Promise<MiddlewareEvent | InternalResult> {
5852
const headers = internalEvent.headers;
@@ -73,7 +67,7 @@ export async function handleMiddleware(
7367
if (!hasMatch) return internalEvent;
7468

7569
const initialUrl = new URL(normalizedPath, internalEvent.url);
76-
initialUrl.search = convertToQueryString(internalEvent.query);
70+
initialUrl.search = initialSearch;
7771
const url = initialUrl.href;
7872

7973
const middleware = await middlewareLoader();

packages/open-next/src/core/routing/util.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Readable } from "node:stream";
55
import { BuildId, HtmlPages, NextConfig } from "config/index.js";
66
import type { IncomingMessage } from "http/index.js";
77
import { OpenNextNodeResponse } from "http/openNextResponse.js";
8-
import { parseHeaders } from "http/util.js";
8+
import { getQueryFromIterator, parseHeaders } from "http/util.js";
99
import type {
1010
FunctionsConfigManifest,
1111
MiddlewareManifest,
@@ -38,13 +38,11 @@ export function isExternal(url?: string, host?: string) {
3838
export function convertFromQueryString(query: string) {
3939
if (query === "") return {};
4040
const queryParts = query.split("&");
41-
return queryParts.reduce(
42-
(acc, part) => {
43-
const [key, value] = part.split("=");
44-
acc[key] = value;
45-
return acc;
46-
},
47-
{} as Record<string, string>,
41+
return getQueryFromIterator(
42+
queryParts.map((p) => {
43+
const [key, value] = p.split("=");
44+
return [key, value] as const;
45+
}),
4846
);
4947
}
5048

@@ -122,23 +120,20 @@ export function convertRes(res: OpenNextNodeResponse): InternalResult {
122120
* Make sure that multi-value query parameters are transformed to
123121
* ?key=value1&key=value2&... so that Next converts those parameters
124122
* to an array when reading the query parameters
123+
* query should be properly encoded before using this function
125124
* @__PURE__
126125
*/
127126
export function convertToQueryString(query: Record<string, string | string[]>) {
128-
// URLSearchParams is a representation of the PARSED query.
129-
// So we must decode the value before appending it to the URLSearchParams.
130-
// https://stackoverflow.com/a/45516812
131-
const urlQuery = new URLSearchParams();
127+
const queryStrings: string[] = [];
132128
Object.entries(query).forEach(([key, value]) => {
133129
if (Array.isArray(value)) {
134-
value.forEach((entry) => urlQuery.append(key, decodeURIComponent(entry)));
130+
value.forEach((entry) => queryStrings.push(`${key}=${entry}`));
135131
} else {
136-
urlQuery.append(key, decodeURIComponent(value));
132+
queryStrings.push(`${key}=${value}`);
137133
}
138134
});
139-
const queryString = urlQuery.toString();
140135

141-
return queryString ? `?${queryString}` : "";
136+
return queryStrings.length > 0 ? `?${queryStrings.join("&")}` : "";
142137
}
143138

144139
/**
@@ -182,11 +177,15 @@ export function getMiddlewareMatch(
182177
*
183178
* @__PURE__
184179
*/
185-
export function escapeRegex(str: string) {
186-
return str
180+
export function escapeRegex(
181+
str: string,
182+
{ isPath }: { isPath?: boolean } = {},
183+
) {
184+
const result = str
187185
.replaceAll("(.)", "_µ1_")
188186
.replaceAll("(..)", "_µ2_")
189187
.replaceAll("(...)", "_µ3_");
188+
return isPath ? result : result.replaceAll("+", "_µ4_");
190189
}
191190

192191
/**
@@ -197,7 +196,8 @@ export function unescapeRegex(str: string) {
197196
return str
198197
.replaceAll("_µ1_", "(.)")
199198
.replaceAll("_µ2_", "(..)")
200-
.replaceAll("_µ3_", "(...)");
199+
.replaceAll("_µ3_", "(...)")
200+
.replaceAll("_µ4_", "+");
201201
}
202202

203203
/**

packages/open-next/src/core/routingHandler.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,13 @@ export default async function routingHandler(
100100
return redirect;
101101
}
102102

103-
const eventOrResult = await handleMiddleware(internalEvent);
103+
const eventOrResult = await handleMiddleware(
104+
internalEvent,
105+
// We need to pass the initial search without any decoding
106+
// TODO: we'd need to refactor InternalEvent to include the initial querystring directly
107+
// Should be done in another PR because it is a breaking change
108+
new URL(event.url).search,
109+
);
104110
const isResult = "statusCode" in eventOrResult;
105111
if (isResult) {
106112
return eventOrResult;

packages/open-next/src/http/util.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,25 @@ export function parseCookies(
3939
? cookies.split(/(?<!Expires=\w+),/i).map((c) => c.trim())
4040
: cookies;
4141
}
42+
43+
/**
44+
*
45+
* Get the query object from an iterable of [key, value] pairs
46+
* @param it - The iterable of [key, value] pairs
47+
* @returns The query object
48+
*/
49+
export function getQueryFromIterator(it: Iterable<[string, string]>) {
50+
const query: Record<string, string | string[]> = {};
51+
for (const [key, value] of it) {
52+
if (key in query) {
53+
if (Array.isArray(query[key])) {
54+
query[key].push(value);
55+
} else {
56+
query[key] = [query[key], value];
57+
}
58+
} else {
59+
query[key] = value;
60+
}
61+
}
62+
return query;
63+
}

packages/open-next/src/overrides/converters/edge.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
MiddlewareResult,
88
} from "types/open-next";
99
import type { Converter } from "types/overrides";
10+
import { getQueryFromSearchParams } from "./utils.js";
1011

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

2021
const searchParams = url.searchParams;
21-
const query: Record<string, string | string[]> = {};
22-
for (const [key, value] of searchParams.entries()) {
23-
if (key in query) {
24-
if (Array.isArray(query[key])) {
25-
query[key].push(value);
26-
} else {
27-
query[key] = [query[key], value];
28-
}
29-
} else {
30-
query[key] = value;
31-
}
32-
}
22+
const query = getQueryFromSearchParams(searchParams);
3323
// Transform body into Buffer
3424
const body = await event.arrayBuffer();
3525
const headers: Record<string, string> = {};

packages/open-next/src/overrides/converters/node.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { IncomingMessage } from "node:http";
33
import { parseCookies } from "http/util";
44
import type { InternalResult } from "types/open-next";
55
import type { Converter } from "types/overrides";
6-
import { extractHostFromHeaders } from "./utils";
6+
import { extractHostFromHeaders, getQueryFromSearchParams } from "./utils.js";
77

88
const converter: Converter = {
99
convertFrom: async (req: IncomingMessage) => {
@@ -26,7 +26,7 @@ const converter: Converter = {
2626
.filter(([key]) => key),
2727
);
2828
const url = new URL(req.url!, `http://${extractHostFromHeaders(headers)}`);
29-
const query = Object.fromEntries(url.searchParams.entries());
29+
const query = getQueryFromSearchParams(url.searchParams);
3030
return {
3131
type: "core",
3232
method: req.method ?? "GET",

packages/open-next/src/overrides/converters/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { getQueryFromIterator } from "http/util.js";
2+
13
export function removeUndefinedFromQuery(
24
query: Record<string, string | string[] | undefined>,
35
) {
@@ -21,3 +23,13 @@ export function extractHostFromHeaders(
2123
): string {
2224
return headers["x-forwarded-host"] ?? headers.host ?? "on";
2325
}
26+
27+
/**
28+
* Get the query object from an URLSearchParams
29+
*
30+
* @param searchParams
31+
* @returns
32+
*/
33+
export function getQueryFromSearchParams(searchParams: URLSearchParams) {
34+
return getQueryFromIterator(searchParams.entries());
35+
}

0 commit comments

Comments
 (0)