Skip to content

Commit 2c96039

Browse files
AbanoubGhadbanclaudejustin808
authored
Replace NDJSON envelope with length-prefixed streaming protocol (#2615)
## Summary Closes #2614 - Replace `JSON.stringify` wrapping of HTML content in the Node→Ruby streaming protocol with a length-prefixed format that sends raw content bytes without escaping - Auto-detect format on Ruby side for backward compatibility with older node renderers - Refactor console replay logging into a shared method to handle both Hash and String chunks ## Problem The current NDJSON protocol wraps every HTML chunk in a JSON envelope: ```json {"html":"<div>...escaped HTML...</div>","consoleReplayScript":"","hasErrors":false,"isShellReady":true}\n ``` `JSON.stringify` escapes every `"`, `\`, and `\n` in the HTML content — adding **~30% overhead** for typical payloads. Ruby then `JSON.parse`s each line, undoing all the escaping. This serialize→deserialize round-trip is pure waste for the bulk content (HTML is 99.9% of each chunk by size). ## Solution New wire format per chunk: ``` <metadata JSON>\t<content byte length hex>\n<raw content bytes> ``` Example: ``` {"consoleReplayScript":"","hasErrors":false,"isShellReady":true}\t00028A3C\n<div>...(raw HTML, zero escaping)... ``` - **Metadata** (~80 bytes): small JSON without the `html` field — negligible escaping cost - **Content** (bulk): raw bytes with length prefix — **zero** escaping overhead - **Ruby parser**: reads header line (until `\n`), extracts content length, reads exactly that many raw bytes - **Auto-detection**: if the header line contains `\t`, it's length-prefixed; otherwise legacy NDJSON (backward compatible) ## Changes | File | Change | |------|--------| | `streamingUtils.ts` | Write `metadata\tcontent_length\nraw_content` instead of `JSON.stringify(envelope)\n` | | `stream_request.rb` | Replace `loop_response_lines` with `loop_response_chunks` (length-prefixed parser with auto-detection) | | `ruby_embedded_java_script.rb` | Handle both Hash (new) and String (legacy) chunks; extract shared `replay_console_to_rails_logger` | | `streamServerRenderedReactComponent.test.jsx` | Update `expectStreamChunk` to parse length-prefixed format | | `removeRSCChunkStack.ts` | Update to parse length-prefixed format with NDJSON fallback | ## Test plan - [x] `streamServerRenderedReactComponent.test.jsx` — 16/16 pass (core streaming tests) - [x] `concurrentRSCPayloadGeneration.rsc.test.tsx` — pass (parallel RSC rendering) - [x] `RSCSerialization.rsc.test.tsx` — pass - [x] `ReactOnRailsRSC.rsc.test.tsx` — pass - [x] `serverRenderRSCReactComponent.rsc.test.tsx` — pass - [x] `injectRSCPayload.test.ts` — 9/9 pass - [ ] Integration tests with real node renderer - [ ] E2E tests with dummy app 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Improvements** * Switched streaming to a length‑prefixed protocol for atomic chunk delivery, correct multibyte handling, and compatibility with legacy newline‑delimited streams. * More reliable stream parsing with improved server-side console replay/logging, clearer error signaling, and robust end‑of‑stream handling. * **Tests** * Added tests validating length‑prefixed parsing, including UTF‑8 multibyte content. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: Justin Gordon <[email protected]>
1 parent da37b46 commit 2c96039

39 files changed

Lines changed: 864 additions & 498 deletions

packages/react-on-rails-pro-node-renderer/src/worker/vm.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,8 +415,10 @@ export async function buildExecutionContext(
415415
return newStreamAfterHandlingError;
416416
}
417417
if (typeof result !== 'string') {
418-
const objectResult = await result;
419-
result = JSON.stringify(objectResult);
418+
const resolvedResult = await result;
419+
// If the resolved value is already a string (e.g., length-prefixed format from
420+
// buildLengthPrefixedResult), use it directly. Only JSON.stringify objects.
421+
result = typeof resolvedResult === 'string' ? resolvedResult : JSON.stringify(resolvedResult);
420422
}
421423
if (log.level === 'debug' && result) {
422424
log.debug(`result from JS:

packages/react-on-rails-pro-node-renderer/tests/concurrentHtmlStreaming.test.ts

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ import { randomUUID } from 'crypto';
22
import { createClient } from 'redis';
33
import parser from 'node-html-parser';
44

5-
// eslint-disable-next-line import/no-relative-packages
6-
import { RSCPayloadChunk } from '../../react-on-rails/lib/types';
75
import buildApp from '../src/worker';
86
import { createTestConfig } from './testingNodeRendererConfigs';
97
import { makeRequest } from './httpRequestUtils';
8+
import { LengthPrefixedStreamParser } from './parseLengthPrefixedStream';
109

1110
const { config } = createTestConfig('concurrentHtmlStreaming');
1211
const app = buildApp(config);
@@ -32,11 +31,13 @@ const sendRedisItemValue = async (redisRequestId: string, itemIndex: number, val
3231
await sendRedisValue(redisRequestId, `Item${itemIndex}`, value);
3332
};
3433

35-
const extractHtmlFromChunks = (chunks: string) => {
36-
const html = chunks
37-
.split('\n')
38-
.map((chunk) => (chunk.trim().length > 0 ? (JSON.parse(chunk) as RSCPayloadChunk).html : chunk))
39-
.join('');
34+
const extractHtmlFromChunks = (chunks: string, valuesToStrip: string[] = []) => {
35+
const streamParser = new LengthPrefixedStreamParser();
36+
streamParser.feed(chunks);
37+
let html = streamParser.htmlChunks.join('');
38+
valuesToStrip.forEach((value) => {
39+
html = html.replace(new RegExp(value, 'g'), '');
40+
});
4041
const parsedHtml = parser.parse(html);
4142
// TODO: investigate why ReactOnRails produces different RSC payload on each request
4243
parsedHtml.querySelectorAll('script').forEach((x) => x.remove());
@@ -55,16 +56,13 @@ const createParallelRenders = (size: number) => {
5556
});
5657
});
5758

58-
const expectNextChunk = async (expectedNextChunk: string) => {
59+
const expectNextChunk = async (expectedNextChunkHtml: string) => {
5960
const nextChunks = await Promise.all(
6061
renderRequests.map((renderRequest) => renderRequest.waitForNextChunk()),
6162
);
6263
nextChunks.forEach((chunk, index) => {
6364
const redisRequestId = redisRequestIds[index]!;
64-
const chunksAfterRemovingRequestId = chunk.replace(new RegExp(redisRequestId, 'g'), '');
65-
expect(extractHtmlFromChunks(chunksAfterRemovingRequestId)).toEqual(
66-
extractHtmlFromChunks(expectedNextChunk),
67-
);
65+
expect(extractHtmlFromChunks(chunk, [redisRequestId])).toEqual(expectedNextChunkHtml);
6866
});
6967
};
7068

@@ -92,55 +90,55 @@ test('Happy Path', async () => {
9290
const parallelInstances = 50;
9391
expect.assertions(parallelInstances * 7 + 7);
9492
const redisRequestId = randomUUID();
93+
const expectedChunks: string[] = [];
9594
const { waitForNextChunk, finishedPromise, getBuffer } = makeRequest(app, {
9695
componentName: 'RedisReceiver',
9796
props: { requestId: redisRequestId },
9897
});
99-
const chunks: string[] = [];
10098
let chunk = await waitForNextChunk();
10199
expect(chunk).not.toContain('Unique Value');
102-
chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), ''));
100+
expectedChunks.push(extractHtmlFromChunks(chunk, [redisRequestId]));
103101

104102
await sendRedisItemValue(redisRequestId, 0, 'First Unique Value');
105103
chunk = await waitForNextChunk();
106104
expect(chunk).toContain('First Unique Value');
107-
chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), ''));
105+
expectedChunks.push(extractHtmlFromChunks(chunk, [redisRequestId]));
108106

109107
await sendRedisItemValue(redisRequestId, 4, 'Fifth Unique Value');
110108
chunk = await waitForNextChunk();
111109
expect(chunk).toContain('Fifth Unique Value');
112-
chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), ''));
110+
expectedChunks.push(extractHtmlFromChunks(chunk, [redisRequestId]));
113111

114112
await sendRedisItemValue(redisRequestId, 2, 'Third Unique Value');
115113
chunk = await waitForNextChunk();
116114
expect(chunk).toContain('Third Unique Value');
117-
chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), ''));
115+
expectedChunks.push(extractHtmlFromChunks(chunk, [redisRequestId]));
118116

119117
await sendRedisItemValue(redisRequestId, 1, 'Second Unique Value');
120118
chunk = await waitForNextChunk();
121119
expect(chunk).toContain('Second Unique Value');
122-
chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), ''));
120+
expectedChunks.push(extractHtmlFromChunks(chunk, [redisRequestId]));
123121

124122
await sendRedisItemValue(redisRequestId, 3, 'Forth Unique Value');
125123
chunk = await waitForNextChunk();
126124
expect(chunk).toContain('Forth Unique Value');
127-
chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), ''));
125+
expectedChunks.push(extractHtmlFromChunks(chunk, [redisRequestId]));
128126

129127
await finishedPromise;
130128
expect(getBuffer).toHaveLength(0);
131129

132130
const { expectNextChunk, sendRedisItemValues, waitUntilFinished } =
133131
createParallelRenders(parallelInstances);
134-
await expectNextChunk(chunks[0]!);
132+
await expectNextChunk(expectedChunks[0]!);
135133
await sendRedisItemValues(0, 'First Unique Value');
136-
await expectNextChunk(chunks[1]!);
134+
await expectNextChunk(expectedChunks[1]!);
137135
await sendRedisItemValues(4, 'Fifth Unique Value');
138-
await expectNextChunk(chunks[2]!);
136+
await expectNextChunk(expectedChunks[2]!);
139137
await sendRedisItemValues(2, 'Third Unique Value');
140-
await expectNextChunk(chunks[3]!);
138+
await expectNextChunk(expectedChunks[3]!);
141139
await sendRedisItemValues(1, 'Second Unique Value');
142-
await expectNextChunk(chunks[4]!);
140+
await expectNextChunk(expectedChunks[4]!);
143141
await sendRedisItemValues(3, 'Forth Unique Value');
144-
await expectNextChunk(chunks[5]!);
142+
await expectNextChunk(expectedChunks[5]!);
145143
await waitUntilFinished();
146144
}, 50000);

packages/react-on-rails-pro-node-renderer/tests/handleRenderRequest.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ describe(testName, () => {
405405
expect(result.response).toEqual({
406406
status: 200,
407407
headers: { 'Cache-Control': 'public, max-age=31536000' },
408-
data: JSON.stringify('undefined'),
408+
data: 'undefined',
409409
});
410410
});
411411

packages/react-on-rails-pro-node-renderer/tests/htmlStreaming.test.js

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import buildApp from '../src/worker';
33
import { createTestConfig } from './testingNodeRendererConfigs';
44
import * as errorReporter from '../src/shared/errorReporter';
55
import { createForm, SERVER_BUNDLE_TIMESTAMP } from './httpRequestUtils';
6+
import { LengthPrefixedStreamParser } from './parseLengthPrefixedStream';
67

78
const { config } = createTestConfig('htmlStreaming');
89
const app = buildApp(config);
@@ -30,34 +31,16 @@ const makeRequest = async (options = {}) => {
3031
});
3132
request.setEncoding('utf8');
3233

33-
const chunks = [];
34-
const jsonChunks = [];
34+
const parser = new LengthPrefixedStreamParser();
3535
let firstByteTime;
3636
let status;
37-
const decoder = new TextDecoder();
3837

3938
request.on('response', (headers) => {
4039
status = headers[':status'];
4140
});
4241

4342
request.on('data', (data) => {
44-
// Sometimes, multiple chunks are merged into one.
45-
// So, the server uses \n as a delimiter between chunks.
46-
const decodedData = typeof data === 'string' ? data : decoder.decode(data, { stream: false });
47-
const decodedChunksFromData = decodedData
48-
.split('\n')
49-
.map((chunk) => chunk.trim())
50-
.filter((chunk) => chunk.length > 0);
51-
chunks.push(...decodedChunksFromData);
52-
jsonChunks.push(
53-
...decodedChunksFromData.map((chunk) => {
54-
try {
55-
return JSON.parse(chunk);
56-
} catch (e) {
57-
return { hasErrors: true, error: `JSON parsing failed: ${e.message}` };
58-
}
59-
}),
60-
);
43+
parser.feed(data);
6144
if (!firstByteTime) {
6245
firstByteTime = Date.now();
6346
}
@@ -80,6 +63,7 @@ const makeRequest = async (options = {}) => {
8063
});
8164

8265
const endTime = Date.now();
66+
const { htmlChunks: chunks, parsedChunks: jsonChunks } = parser;
8367
const fullBody = chunks.join('');
8468
const timeToFirstByte = firstByteTime - startTime;
8569
const streamingTime = endTime - firstByteTime;

packages/react-on-rails-pro-node-renderer/tests/httpRequestUtils.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,7 @@ export const makeRequest = (app: ReturnType<typeof buildApp>, options: Partial<R
122122
':path': `/bundles/${usedBundleTimestamp}/render/454a82526211afdb215352755d36032c`,
123123
'content-type': `multipart/form-data; boundary=${form.getBoundary()}`,
124124
});
125-
request.setEncoding('utf8');
126-
127-
const buffer: string[] = [];
125+
const buffer: Buffer[] = [];
128126

129127
const statusPromise = new Promise<number | undefined>((resolve) => {
130128
request.on('response', (headers) => {
@@ -142,15 +140,15 @@ export const makeRequest = (app: ReturnType<typeof buildApp>, options: Partial<R
142140
}
143141

144142
resolveChunkPromiseTimeout = setTimeout(() => {
145-
resolveChunksPromise?.(buffer.join(''));
143+
resolveChunksPromise?.(Buffer.concat(buffer).toString('utf8'));
146144
resolveChunksPromise = undefined;
147145
rejectChunksPromise = undefined;
148146
buffer.length = 0;
149147
}, 1000);
150148
};
151149

152-
request.on('data', (data: Buffer) => {
153-
buffer.push(data.toString());
150+
request.on('data', (data: Buffer | string) => {
151+
buffer.push(Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8'));
154152
if (resolveChunksPromise) {
155153
scheduleResolveChunkPromise();
156154
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Test utility that wraps the production LengthPrefixedStreamParser with a
3+
* convenience API for collecting parsed chunks. Uses the same parser as
4+
* production code — no duplicated parsing logic.
5+
*/
6+
7+
// eslint-disable-next-line import/no-relative-packages
8+
import ProductionParser from '../../react-on-rails-pro/src/parseLengthPrefixedStream';
9+
10+
export interface ParsedChunk {
11+
html: string;
12+
consoleReplayScript?: string;
13+
hasErrors?: boolean;
14+
isShellReady?: boolean;
15+
renderingError?: { message: string; stack: string };
16+
[key: string]: unknown;
17+
}
18+
19+
export class LengthPrefixedStreamParser {
20+
private parser = new ProductionParser();
21+
22+
readonly htmlChunks: string[] = [];
23+
24+
readonly parsedChunks: ParsedChunk[] = [];
25+
26+
feed(data: string | Buffer): void {
27+
const buf = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
28+
this.parser.feed(buf, (content, metadata) => {
29+
const html = new TextDecoder().decode(content);
30+
this.htmlChunks.push(html);
31+
this.parsedChunks.push({ html, ...metadata } as ParsedChunk);
32+
});
33+
}
34+
}

packages/react-on-rails-pro-node-renderer/tests/streamErrorHang.test.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import fs from 'fs';
11+
import { LengthPrefixedStreamParser } from './parseLengthPrefixedStream';
1112
import path from 'path';
1213
import http2 from 'http2';
1314
import FormData from 'form-data';
@@ -69,7 +70,7 @@ const makeRequest = (renderingRequest: string, timeoutMs = 3000) =>
6970
});
7071
request.setEncoding('utf8');
7172

72-
const chunks: string[] = [];
73+
const parser = new LengthPrefixedStreamParser();
7374
let status: number | undefined;
7475
let settled = false;
7576

@@ -78,11 +79,7 @@ const makeRequest = (renderingRequest: string, timeoutMs = 3000) =>
7879
});
7980

8081
request.on('data', (data: string) => {
81-
const decoded = data
82-
.split('\n')
83-
.map((c) => c.trim())
84-
.filter((c) => c.length > 0);
85-
chunks.push(...decoded);
82+
parser.feed(data);
8683
});
8784

8885
form.pipe(request);
@@ -92,7 +89,7 @@ const makeRequest = (renderingRequest: string, timeoutMs = 3000) =>
9289
if (settled) return;
9390
settled = true;
9491
client.destroy();
95-
resolve({ status, chunks, timedOut });
92+
resolve({ status, chunks: parser.htmlChunks, timedOut });
9693
};
9794

9895
const timeout = setTimeout(() => finish(true), timeoutMs);
@@ -112,12 +109,27 @@ const makeRequest = (renderingRequest: string, timeoutMs = 3000) =>
112109
// The bundle exposes `Readable` globally via `global.Readable = require('stream').Readable`.
113110
// ---------------------------------------------------------------------------
114111

112+
// Helper: builds a length-prefixed chunk string for the mock rendering requests.
113+
// Format: <metadata JSON>\t<content byte length hex>\n<raw content bytes>
114+
function buildLengthPrefixedChunk(html: string, metadata: Record<string, unknown> = {}): string {
115+
const meta = {
116+
consoleReplayScript: '',
117+
hasErrors: false,
118+
isShellReady: true,
119+
payloadType: 'string',
120+
...metadata,
121+
};
122+
const metaJson = JSON.stringify(meta);
123+
const byteLen = Buffer.byteLength(html).toString(16).padStart(8, '0');
124+
return `${metaJson}\t${byteLen}\n${html}`;
125+
}
126+
115127
const RENDERING_REQUEST = {
116128
/** Pushes one chunk, then destroys the stream with an error. */
117129
errorMidStream: `(function() {
118130
var stream = new Readable({ read() {} });
119131
setTimeout(function() {
120-
stream.push('{"html":"<div>partial</div>","consoleReplayScript":"","hasErrors":false,"isShellReady":true}\\n');
132+
stream.push(${JSON.stringify(buildLengthPrefixedChunk('<div>partial</div>'))});
121133
}, 10);
122134
setTimeout(function() {
123135
stream.destroy(new Error('mid-stream rendering error'));
@@ -138,7 +150,7 @@ const RENDERING_REQUEST = {
138150
happyPath: `(function() {
139151
var stream = new Readable({ read() {} });
140152
setTimeout(function() {
141-
stream.push('{"html":"<div>ok</div>","consoleReplayScript":"","hasErrors":false,"isShellReady":true}\\n');
153+
stream.push(${JSON.stringify(buildLengthPrefixedChunk('<div>ok</div>'))});
142154
stream.push(null);
143155
}, 10);
144156
return stream;

0 commit comments

Comments
 (0)