Skip to content
Open
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
8 changes: 6 additions & 2 deletions packages/react-router/src/cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export function injectTraceMetaTags(body: ReadableStream): ReadableStream {
const headClosingTag = '</head>';

const reader = body.getReader();
const encoder = new TextEncoder();
// A single streaming decoder carries incomplete multi-byte sequences across chunk
// boundaries. A fresh, non-streaming decoder per chunk would flush a split character
// as U+FFFD, corrupting the response (see https://github.com/whatwg/encoding/issues/184).
const decoder = new TextDecoder();
const stream = new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
Expand All @@ -23,8 +28,7 @@ export function injectTraceMetaTags(body: ReadableStream): ReadableStream {
return;
}

const encoder = new TextEncoder();
const html = value instanceof Uint8Array ? new TextDecoder().decode(value) : String(value);
const html = value instanceof Uint8Array ? decoder.decode(value, { stream: true }) : String(value);

if (html.includes(headClosingTag)) {
const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`);
Expand Down
9 changes: 7 additions & 2 deletions packages/react-router/src/server/getMetaTagTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,20 @@ import { getTraceMetaTags } from '@sentry/core';
*/
export function getMetaTagTransformer(body: PassThrough): Transform {
const headClosingTag = '</head>';
// A single streaming decoder carries incomplete multi-byte sequences across chunk
// boundaries. Decoding each chunk on its own (e.g. `Buffer.toString()`) would flush a
// split character as U+FFFD, corrupting the response (see
// https://github.com/whatwg/encoding/issues/184).
const decoder = new TextDecoder();
const htmlMetaTagTransformer = new Transform({
transform(chunk, _encoding, callback) {
const html = Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk);
const html = Buffer.isBuffer(chunk) ? decoder.decode(chunk, { stream: true }) : String(chunk);
if (html.includes(headClosingTag)) {
const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`);
callback(null, modifiedHtml);
return;
}
callback(null, chunk);
callback(null, html);
},
});
htmlMetaTagTransformer.pipe(body);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// @vitest-environment node
import type * as SentryCore from '@sentry/core';
import { getTraceMetaTags } from '@sentry/core';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { injectTraceMetaTags } from '../../src/cloudflare/index';

vi.mock('@sentry/core', async importOriginal => ({
...(await importOriginal<typeof SentryCore>()),
getTraceMetaTags: vi.fn(),
}));

function streamFromChunks(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
});
}

async function readAll(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = stream.getReader();
const parts: Uint8Array[] = [];
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
parts.push(value);
}
const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const p of parts) {
out.set(p, offset);
offset += p.length;
}
return out;
}

const REPLACEMENT_CHARACTER = '�';

describe('injectTraceMetaTags', () => {
beforeEach(() => {
vi.clearAllMocks();
(getTraceMetaTags as unknown as ReturnType<typeof vi.fn>).mockReturnValue(
'<meta name="sentry-trace" content="test-trace-id">',
);
});

test('injects meta tags before the closing head tag', async () => {
const encoder = new TextEncoder();
const input = streamFromChunks([encoder.encode('<html><head></head><body>Test</body></html>')]);

const output = new TextDecoder().decode(await readAll(injectTraceMetaTags(input)));

expect(output).toContain('<meta name="sentry-trace" content="test-trace-id"></head>');
expect(output).not.toContain('</head></head>');
});

test('preserves a multi-byte character split across chunk boundaries', async () => {
// `©` is 0xC2 0xA9 in UTF-8. Splitting it across two chunks must not corrupt it.
const encoder = new TextEncoder();
const before = encoder.encode('<html><head></head><body><p>');
const after = encoder.encode(' 2026</p></body></html>');

const input = streamFromChunks([
new Uint8Array([...before, 0xc2]), // first byte of `©`
new Uint8Array([0xa9, ...after]), // second byte of `©`
]);

const outputBytes = await readAll(injectTraceMetaTags(input));
const output = new TextDecoder('utf-8', { fatal: false }).decode(outputBytes);

expect(output).not.toContain(REPLACEMENT_CHARACTER);
expect(output).toContain('<p>© 2026</p>');
expect(output).toContain('<meta name="sentry-trace" content="test-trace-id"></head>');
});

test('preserves a multi-byte character split across a chunk after </head>', async () => {
// The corruption is not limited to the `</head>` chunk: every chunk is round-tripped.
const encoder = new TextEncoder();
const head = encoder.encode('<html><head></head><body>');
const tail = encoder.encode(' inside body</body></html>');

const input = streamFromChunks([
head,
new Uint8Array([0xe2, 0x82]), // first two bytes of `€` (0xE2 0x82 0xAC)
new Uint8Array([0xac, ...tail]), // final byte of `€`
]);

const output = new TextDecoder('utf-8', { fatal: false }).decode(await readAll(injectTraceMetaTags(input)));

expect(output).not.toContain(REPLACEMENT_CHARACTER);
expect(output).toContain('€ inside body');
});
});
28 changes: 28 additions & 0 deletions packages/react-router/test/server/getMetaTagTransformer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,32 @@ describe('getMetaTagTransformer', () => {
transformer.write('</html>');
transformer.end();
}));

test('should not corrupt a multi-byte character split across the head-closing chunk', () =>
new Promise<void>((resolve, reject) => {
const bodyStream = new PassThrough();
const transformer = getMetaTagTransformer(bodyStream);

const outputChunks: Buffer[] = [];
bodyStream.on('data', chunk => {
outputChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});

bodyStream.on('end', () => {
try {
const output = Buffer.concat(outputChunks).toString('utf-8');
expect(output).not.toContain('�');
expect(output).toContain('<p>© 2026</p>');
expect(output).toContain('<meta name="sentry-trace" content="test-trace-id"></head>');
resolve();
} catch (e) {
reject(e);
}
});

// `©` is 0xC2 0xA9 in UTF-8; the closing-head chunk ends mid-character.
transformer.write(Buffer.from([...Buffer.from('<html><head></head><body><p>'), 0xc2]));
transformer.write(Buffer.from([0xa9, ...Buffer.from(' 2026</p></body></html>')]));
transformer.end();
}));
});
Loading