diff --git a/.changeset/ascii-safe-props-header.md b/.changeset/ascii-safe-props-header.md new file mode 100644 index 00000000..2f2e153b --- /dev/null +++ b/.changeset/ascii-safe-props-header.md @@ -0,0 +1,5 @@ +--- +"partyserver": patch +--- + +Encode the `x-partykit-props` header as base64 so props containing non-ASCII characters (e.g. accented names like "Usuário") no longer trigger workerd's "header value contains non-ASCII characters" warning, which would throw a `TypeError` in browser fetch implementations. The header is decoded back to the original Unicode payload on the server, and raw-JSON values from older callers are still accepted for backwards compatibility. diff --git a/packages/partyserver/src/index.ts b/packages/partyserver/src/index.ts index 35cbcf1c..bec3dd0c 100644 --- a/packages/partyserver/src/index.ts +++ b/packages/partyserver/src/index.ts @@ -237,6 +237,45 @@ async function retryDurableObjectOperation( } } +/** + * Encode props for the `x-partykit-props` header. + * + * The value travels in an HTTP header, so it must be ASCII-safe. Raw + * `JSON.stringify` output can contain non-ASCII characters (e.g. accented + * names like "Usuário"), which makes workerd emit a "header value contains + * non-ASCII characters" warning and throws in browser fetch implementations. + * We UTF-8 encode the JSON and base64 it so the header is always ASCII. + */ +function encodeProps(props: unknown): string { + const bytes = new TextEncoder().encode(JSON.stringify(props)); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/** + * Decode props from the `x-partykit-props` header. + * + * Handles both the base64 encoding produced by `encodeProps` and, for + * backwards compatibility with stubs/requests created by older versions, + * raw JSON. Base64 never starts with `{` or `[`, so a leading brace/bracket + * unambiguously identifies the legacy raw-JSON form. + */ +function decodeProps(header: string): unknown { + const trimmed = header.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + return JSON.parse(trimmed); + } + const binary = atob(header); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return JSON.parse(new TextDecoder().decode(bytes)); +} + /** * For a given server namespace, create a server with a name. * @@ -529,7 +568,7 @@ Did you forget to add a durable object binding to the class ${namespace[0].toUpp // onBeforeConnect / onBeforeRequest callbacks don't see the serialized // props header on the inspection request. if (options?.props !== undefined) { - req.headers.set("x-partykit-props", JSON.stringify(options.props)); + req.headers.set("x-partykit-props", encodeProps(options.props)); } // Single RPC for both WS and HTTP: `this.name` is populated from @@ -608,7 +647,7 @@ export class Server< // Set the props in-mem if the request included them. const props = request.headers.get("x-partykit-props"); if (props) { - this.#_props = JSON.parse(props); + this.#_props = decodeProps(props) as Props; } // Name resolution priority: ctx.id.name > x-partykit-room header diff --git a/packages/partyserver/src/tests/index.test.ts b/packages/partyserver/src/tests/index.test.ts index f846e502..b4a9159c 100644 --- a/packages/partyserver/src/tests/index.test.ts +++ b/packages/partyserver/src/tests/index.test.ts @@ -1646,6 +1646,33 @@ describe("Props via x-partykit-props header", () => { expect(response.status).toBe(200); expect(request.headers.get("x-partykit-props")).toBeNull(); }); + + // Regression for cloudflare/agents#1751: non-ASCII props (e.g. accented + // names) used to be written into the x-partykit-props header verbatim, + // triggering workerd's "header value contains non-ASCII characters" + // warning (and a TypeError in browser fetch implementations). Props are + // now base64-encoded so the header value is always ASCII while still + // round-tripping the original Unicode payload. + it("encodes non-ASCII props as an ASCII-safe header value", async () => { + const ctx = createExecutionContext(); + const request = new Request( + "http://example.com/unicode-props-parties/props-server/room-unicode" + ); + const response = await worker.fetch(request, env, ctx); + expect(response.status).toBe(200); + const data = (await response.json()) as { + name: string; + props: { secret: string }; + rawPropsHeader: string | null; + }; + expect(data.name).toBe("room-unicode"); + // Props round-trip back to their original Unicode form. + expect(data.props).toEqual({ secret: "Usuário 日本語 🎉" }); + // The header value the DO received must be ASCII-only. + expect(data.rawPropsHeader).not.toBeNull(); + // oxlint-disable-next-line no-control-regex + expect(data.rawPropsHeader).toMatch(/^[\x00-\x7f]*$/); + }); }); /** diff --git a/packages/partyserver/src/tests/worker.ts b/packages/partyserver/src/tests/worker.ts index f5643c70..a2e44f4a 100644 --- a/packages/partyserver/src/tests/worker.ts +++ b/packages/partyserver/src/tests/worker.ts @@ -667,10 +667,12 @@ export class PropsServer extends Server { this.receivedProps = props; } - onRequest(): Response { + onRequest(request: Request): Response { return Response.json({ name: this.name, - props: this.receivedProps + props: this.receivedProps, + // Echo the raw header so tests can verify it is ASCII-safe. + rawPropsHeader: request.headers.get("x-partykit-props") }); } @@ -1017,6 +1019,18 @@ export default { ); } + // Route requests under /unicode-props-parties/ with non-ASCII props. + // Regression coverage for cloudflare/agents#1751: header values must be + // ASCII-safe so workerd doesn't warn (and browsers don't throw). + if (url.pathname.startsWith("/unicode-props-parties/")) { + return ( + (await routePartykitRequest(request, env, { + prefix: "unicode-props-parties", + props: { secret: "Usuário 日本語 🎉" } + })) || new Response("Not Found", { status: 404 }) + ); + } + // Route requests under /cors-parties/ with cors: true if (url.pathname.startsWith("/cors-parties/")) { return (