Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/ascii-safe-props-header.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 41 additions & 2 deletions packages/partyserver/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,45 @@ async function retryDurableObjectOperation<T>(
}
}

/**
* 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.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions packages/partyserver/src/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]*$/);
});
});

/**
Expand Down
18 changes: 16 additions & 2 deletions packages/partyserver/src/tests/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,10 +667,12 @@ export class PropsServer extends Server<Env, { secret: string }> {
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")
});
}

Expand Down Expand Up @@ -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 (
Expand Down
Loading