From 3e4f69f0ef490fc2340a4292ffac875582bd7b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=A1z=C3=A1r?= Date: Thu, 5 Dec 2024 23:46:16 +0100 Subject: [PATCH] fix: encode headers and use charset utf-8 on content-type response headers (#88) Adds encoding to HTTP headers from the client and also adds `charset=utf-8` to all HTTP `Content-Type` headers to support exotic characters in client component and server function paths and also in outlet names. #84 --- .../react-server/client/ClientProvider.jsx | 10 +++-- packages/react-server/lib/handlers/error.mjs | 4 +- packages/react-server/lib/handlers/static.mjs | 7 +++- .../react-server/lib/start/ssr-handler.mjs | 2 +- .../react-server/server/RemoteComponent.jsx | 2 +- packages/react-server/server/redirects.mjs | 2 +- packages/react-server/server/render-rsc.jsx | 42 ++++++++++++------- packages/react-server/server/request.mjs | 4 +- 8 files changed, 44 insertions(+), 29 deletions(-) diff --git a/packages/react-server/client/ClientProvider.jsx b/packages/react-server/client/ClientProvider.jsx index 8b80ba4..0a4896d 100644 --- a/packages/react-server/client/ClientProvider.jsx +++ b/packages/react-server/client/ClientProvider.jsx @@ -243,7 +243,7 @@ export const streamOptions = (outlet, remote) => ({ ) ? {} : { - "React-Server-Action": id, + "React-Server-Action": encodeURIComponent(id), }, }); emit(target, url, (err, result) => { @@ -258,8 +258,8 @@ export const streamOptions = (outlet, remote) => ({ body: formData, headers: { accept: "application/json", - "React-Server-Action": id, - "React-Server-Outlet": outlet || PAGE_ROOT, + "React-Server-Action": encodeURIComponent(id), + "React-Server-Outlet": encodeURIComponent(outlet || PAGE_ROOT), }, } ); @@ -304,7 +304,9 @@ function getFlightResponse(url, options = {}) { accept: `text/x-component${ options.standalone && url !== PAGE_ROOT ? ";standalone" : "" }${options.remote && url !== PAGE_ROOT ? ";remote" : ""}`, - "React-Server-Outlet": options.outlet || PAGE_ROOT, + "React-Server-Outlet": encodeURIComponent( + options.outlet || PAGE_ROOT + ), ...options.headers, }, }), diff --git a/packages/react-server/lib/handlers/error.mjs b/packages/react-server/lib/handlers/error.mjs index 4dc88c9..d502e24 100644 --- a/packages/react-server/lib/handlers/error.mjs +++ b/packages/react-server/lib/handlers/error.mjs @@ -104,7 +104,7 @@ function plainResponse(e) { return new Response(e?.stack ?? null, { ...httpStatus, headers: { - "Content-Type": "text/plain", + "Content-Type": "text/plain; charset=utf-8", ...(getContext(HTTP_HEADERS) ?? {}), }, }); @@ -164,7 +164,7 @@ export default async function errorHandler(err) { { ...httpStatus, headers: { - "Content-Type": "text/html", + "Content-Type": "text/html; charset=utf-8", ...(getContext(HTTP_HEADERS) ?? {}), }, } diff --git a/packages/react-server/lib/handlers/static.mjs b/packages/react-server/lib/handlers/static.mjs index 62687d3..982cbbc 100644 --- a/packages/react-server/lib/handlers/static.mjs +++ b/packages/react-server/lib/handlers/static.mjs @@ -50,7 +50,7 @@ export default async function staticHandler(dir, options = {}) { if (pathname.startsWith("/@source")) { return new Response(await readFile(pathname.slice(8), "utf8"), { headers: { - "content-type": "text/plain", + "content-type": "text/plain; charset=utf-8", }, }); } @@ -178,7 +178,10 @@ export default async function staticHandler(dir, options = {}) { } return new Response(res, { headers: { - "content-type": file.mime, + "content-type": + file.mime.includes("text/") || file.mime === "application/json" + ? `${file.mime}; charset=utf-8` + : file.mime, "content-length": file.stats.size, etag: file.etag, "cache-control": diff --git a/packages/react-server/lib/start/ssr-handler.mjs b/packages/react-server/lib/start/ssr-handler.mjs index a3addc8..2f4e97a 100644 --- a/packages/react-server/lib/start/ssr-handler.mjs +++ b/packages/react-server/lib/start/ssr-handler.mjs @@ -127,7 +127,7 @@ export default async function ssrHandler(root, options = {}) { return new Response(e?.stack ?? null, { ...httpStatus, headers: { - "Content-Type": "text/plain", + "Content-Type": "text/plain; charset=utf-8", ...(getContext(HTTP_HEADERS) ?? {}), }, }); diff --git a/packages/react-server/server/RemoteComponent.jsx b/packages/react-server/server/RemoteComponent.jsx index d96a377..d81e43c 100644 --- a/packages/react-server/server/RemoteComponent.jsx +++ b/packages/react-server/server/RemoteComponent.jsx @@ -18,7 +18,7 @@ async function RemoteComponentLoader({ url, ttl, request = {}, onError }) { Origin: url.origin, ...request.headers, Accept: "text/html;remote", - "React-Server-Outlet": url.toString(), + "React-Server-Outlet": encodeURIComponent(url.toString()), }, }).catch((e) => { (onError ?? getContext(LOGGER_CONTEXT)?.error)?.(e); diff --git a/packages/react-server/server/redirects.mjs b/packages/react-server/server/redirects.mjs index 225fc62..6003444 100644 --- a/packages/react-server/server/redirects.mjs +++ b/packages/react-server/server/redirects.mjs @@ -23,7 +23,7 @@ export function redirect(url, status = 302) { { status, headers: { - "content-type": "text/html", + "content-type": "text/html; charset=utf-8", Location: url, }, } diff --git a/packages/react-server/server/render-rsc.jsx b/packages/react-server/server/render-rsc.jsx index 1342c6a..8483e08 100644 --- a/packages/react-server/server/render-rsc.jsx +++ b/packages/react-server/server/render-rsc.jsx @@ -66,19 +66,22 @@ export async function render(Component) { const accept = context.request.headers.get("accept"); const remote = accept.includes(";remote"); const standalone = accept.includes(";standalone") || remote; - const outlet = ( - context.request.headers.get("react-server-outlet") ?? "PAGE_ROOT" - ).replace(/[^a-zA-Z0-9_]/g, "_"); + const outlet = decodeURIComponent( + ( + context.request.headers.get("react-server-outlet") ?? "PAGE_ROOT" + ).replace(/[^a-zA-Z0-9_]/g, "_") + ); const isFormData = context.request.headers .get("content-type") ?.includes("multipart/form-data"); let formState; - const serverActionHeader = - context.request.headers.get("react-server-action") ?? null; + const serverActionHeader = decodeURIComponent( + context.request.headers.get("react-server-action") ?? null + ); if ( "POST,PUT,PATCH,DELETE".includes(context.request.method) && - (serverActionHeader || isFormData) + ((serverActionHeader && serverActionHeader !== "null") || isFormData) ) { let action = async function () { throw new Error("Server action not found"); @@ -128,7 +131,7 @@ export async function render(Component) { ); } - if (serverActionHeader) { + if (serverActionHeader && serverActionHeader !== "null") { const [serverReferenceModule, serverReferenceName] = serverActionHeader.split("#"); action = async () => { @@ -157,6 +160,11 @@ export async function render(Component) { } const { data, actionId, error } = await action(); + const httpStatus = getContext(HTTP_STATUS) ?? { + status: 200, + statusText: "OK", + }; + const httpHeaders = getContext(HTTP_HEADERS) ?? {}; if (!isFormData) { if (error) { @@ -165,9 +173,10 @@ export async function render(Component) { return resolve( new Response(JSON.stringify(data), { - status: 200, + ...httpStatus, headers: { - "content-type": "application/json", + "content-type": "application/json; charset=utf-8", + ...httpHeaders, }, }) ); @@ -182,10 +191,11 @@ export async function render(Component) { const [result, key] = formState; return resolve( new Response(JSON.stringify(result), { - status: 200, + ...httpStatus, headers: { - "React-Server-Action-Key": key, - "content-type": "application/json", + "React-Server-Action-Key": encodeURIComponent(key), + "content-type": "application/json; charset=utf-8", + ...httpHeaders, }, }) ); @@ -281,7 +291,7 @@ export async function render(Component) { status: responseFromCache.status, statusText: responseFromCache.statusText, headers: { - "content-type": "text/x-component", + "content-type": "text/x-component; charset=utf-8", "cache-control": context.request.headers.get("cache-control") === "no-cache" @@ -352,7 +362,7 @@ export async function render(Component) { new Response(stream, { ...httpStatus, headers: { - "content-type": "text/x-component", + "content-type": "text/x-component; charset=utf-8", "cache-control": context.request.headers.get("cache-control") === "no-cache" @@ -406,7 +416,7 @@ export async function render(Component) { status: responseFromCache.status, statusText: responseFromCache.statusText, headers: { - "content-type": "text/html", + "content-type": "text/html; charset=utf-8", "cache-control": context.request.headers.get("cache-control") === "no-cache" @@ -500,7 +510,7 @@ export async function render(Component) { new Response(responseStream, { ...httpStatus, headers: { - "content-type": "text/html", + "content-type": "text/html; charset=utf-8", "cache-control": context.request.headers.get("cache-control") === "no-cache" diff --git a/packages/react-server/server/request.mjs b/packages/react-server/server/request.mjs index 7d29ff7..5cd7070 100644 --- a/packages/react-server/server/request.mjs +++ b/packages/react-server/server/request.mjs @@ -62,8 +62,8 @@ export function rewrite(pathname) { } export function useOutlet() { - return ( + return decodeURIComponent( getContext(HTTP_CONTEXT)?.request?.headers?.get("react-server-outlet") ?? - "PAGE_ROOT" + "PAGE_ROOT" ); }