Skip to content

Commit 6cfdaa6

Browse files
fix: percent-encode X-Vinext-Params to allow non-ASCII route params (#677)
Headers.set() requires ByteString values (chars <= U+00FF). When dynamic route params contain non-ASCII characters (Korean, Japanese, etc.), JSON.stringify preserves them verbatim which causes a TypeError on the Headers.set() call in buildAppPageRscResponse. Fix: encodeURIComponent the JSON on the server side before setting the header, and decodeURIComponent on the client side when reading it back during initial hydration and client-side navigation. Closes #676
1 parent fd7618c commit 6cfdaa6

3 files changed

Lines changed: 26 additions & 4 deletions

File tree

packages/vinext/src/server/app-browser-entry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ async function readInitialRscStream(): Promise<ReadableStream<Uint8Array>> {
104104
const paramsHeader = rscResponse.headers.get("X-Vinext-Params");
105105
if (paramsHeader) {
106106
try {
107-
params = JSON.parse(paramsHeader) as Record<string, string | string[]>;
107+
params = JSON.parse(decodeURIComponent(paramsHeader)) as Record<string, string | string[]>;
108108
setClientParams(params);
109109
} catch {
110110
// Ignore malformed param headers and continue with hydration.
@@ -243,7 +243,7 @@ async function main(): Promise<void> {
243243
const paramsHeader = navResponse.headers.get("X-Vinext-Params");
244244
if (paramsHeader) {
245245
try {
246-
setClientParams(JSON.parse(paramsHeader));
246+
setClientParams(JSON.parse(decodeURIComponent(paramsHeader)));
247247
} catch {
248248
setClientParams({});
249249
}

packages/vinext/src/server/app-page-response.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,9 @@ export function buildAppPageRscResponse(
167167
});
168168

169169
if (options.params && Object.keys(options.params).length > 0) {
170-
headers.set("X-Vinext-Params", JSON.stringify(options.params));
170+
// encodeURIComponent so non-ASCII params (e.g. Korean slugs) survive the
171+
// HTTP ByteString constraint — Headers.set() rejects chars above U+00FF.
172+
headers.set("X-Vinext-Params", encodeURIComponent(JSON.stringify(options.params)));
171173
}
172174
if (options.policy.cacheControl) {
173175
headers.set("Cache-Control", options.policy.cacheControl);

tests/app-page-response.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,14 +220,34 @@ describe("app page response helpers", () => {
220220

221221
expect(response.status).toBe(202);
222222
expect(response.headers.get("content-type")).toBe("text/x-component; charset=utf-8");
223-
expect(response.headers.get("x-vinext-params")).toBe('{"slug":"test"}');
223+
expect(response.headers.get("x-vinext-params")).toBe(encodeURIComponent('{"slug":"test"}'));
224224
expect(response.headers.get("cache-control")).toBe("private, max-age=5");
225225
expect(response.headers.get("x-vinext-cache")).toBe("MISS");
226226
expect(response.headers.get("vary")).toBe("RSC, Accept, Next-Router-State-Tree");
227227
expect(response.headers.get("x-vinext-timing")).toBe("10,5,-1");
228228
await expect(response.text()).resolves.toBe("flight");
229229
});
230230

231+
it("percent-encodes X-Vinext-Params so non-ASCII characters survive the ByteString header constraint (issue #676)", () => {
232+
// HTTP headers are ByteStrings: each character value must be <= 255.
233+
// JSON.stringify preserves non-ASCII characters verbatim (e.g. Korean 완 = U+C644 = 50756),
234+
// which causes Headers.set() to throw a TypeError in compliant runtimes.
235+
// The fix: encodeURIComponent the JSON before setting the header.
236+
const koreanSlug = "useState-완전정복";
237+
const response = buildAppPageRscResponse(createBody("flight"), {
238+
middlewareContext: { headers: new Headers(), status: 200 },
239+
params: { slug: [koreanSlug] },
240+
policy: {},
241+
timing: { handlerStart: 0, responseKind: "rsc" },
242+
});
243+
244+
const rawHeader = response.headers.get("x-vinext-params")!;
245+
// Header value must be ASCII-safe (all byte values <= 127 after encoding)
246+
expect(Array.from(rawHeader).every((c) => c.charCodeAt(0) <= 127)).toBe(true);
247+
// Decoding must round-trip back to the original params
248+
expect(JSON.parse(decodeURIComponent(rawHeader))).toEqual({ slug: [koreanSlug] });
249+
});
250+
231251
it("builds HTML responses with draft cookies, preload links, middleware, and timing", async () => {
232252
const middlewareHeaders = new Headers();
233253
middlewareHeaders.append("set-cookie", "mw=1; Path=/");

0 commit comments

Comments
 (0)