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
14 changes: 10 additions & 4 deletions src/lib/payload/fragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,23 @@ type CandidateFragment = {
transportLength: number;
};

const CHAT_SAFE_ASCII_FRAGMENT_CHARS = /^[A-Za-z0-9\-._~=#]+$/;

/**
* Computes the serialized length of a fragment value as it would appear in a
* URL after browser percent-encoding of non-ASCII characters.
* Each non-ASCII UTF-8 byte is encoded as %XX (3 chars per byte).
* Computes the serialized length of a fragment value after conservative transport escaping.
*
* We count non-ASCII code points by their UTF-8 percent-encoded size, and we also treat
* ASCII punctuation outside the URL-unreserved fragment subset as escape-prone because many
* chat/link surfaces rewrite those characters even when a browser would accept them in-place.
* This keeps auto-selection aligned with the product's chat-safe fragment goal, allowing the
* `B.` base64url ARX wire shape to win when punctuation-heavy base76 would grow after sharing.
*/
function computeTransportLength(value: string): number {
let len = 0;
for (let i = 0; i < value.length; i++) {
const cp = value.codePointAt(i)!;
if (cp < 128) {
len += 1;
len += CHAT_SAFE_ASCII_FRAGMENT_CHARS.test(value[i]) ? 1 : 3;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid preferring over-budget B. fragments over valid base76

This 3x penalty means encodeEnvelopeAsync(..., { codec: "arx" }) can now pick the chat-safe B. wire form even when it makes the real fragment exceed the 8,000-character limit that callers still enforce with hash.length - 1 (for example in createGeneratedArtifactLinkAsync). I reproduced this with a packed markdown payload of about 5,900 repeated bullet lines: the base76 fragment is 7,985 chars and would still work, but its computed transport length becomes 10,159 here, so the encoder instead selects an 8,331-char B. fragment and link creation rejects it. That is a user-visible regression for large payloads near the fragment budget.

Useful? React with 👍 / 👎.

} else if (cp < 0x800) {
len += 6; // 2 UTF-8 bytes → %XX%XX
} else if (cp < 0x10000) {
Expand Down
22 changes: 22 additions & 0 deletions tests/arx-codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,28 @@ describe("arx fragment round-trip", () => {
expect(autoHash).toContain(`v1.arx.${getActiveDictVersion()}.`);
});

it("async arx selection can choose the chat-safe base64url wire form", async () => {
const bigEnvelope: PayloadEnvelope = {
...envelope,
artifacts: [
{
id: "doc",
kind: "markdown",
filename: "doc.md",
content: [
"# Chat-safe ARX",
"",
...Array.from({ length: 120 }, (_, index) => `- item ${index}: The quick brown fox jumps over the lazy dog.`),
].join("\n"),
},
],
};

const autoHash = await encodeEnvelopeAsync(bigEnvelope, { codec: "arx" });
expect(autoHash).toContain(`v1.arx.${getActiveDictVersion()}.B.`);
});


it("decodes arx fragments when unicode payload chars are percent-escaped", async () => {
const hash = `#${await encodeEnvelopeAsync(envelope, { codec: "arx" })}`;
const escapedHash = hash.replace(/[^\x00-\x7F]/g, (char) => encodeURIComponent(char));
Expand Down
Loading