Skip to content
Draft
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ
mismatches on streamed RSC apps such as the flagship demo. Fixes
[Issue 4525](https://github.com/shakacode/react_on_rails/issues/4525). [PR 4532](https://github.com/shakacode/react_on_rails/pull/4532) by [justin808](https://github.com/justin808).

#### Changed

- **[Pro] Render requests now send raw JavaScript bodies to the Node renderer**: Non-bundle render
requests use a raw `application/vnd.react-on-rails.render-request+javascript` body with metadata in
`X-React-On-Rails-Pro-*` headers instead of `application/x-www-form-urlencoded`, removing
URL-encoding overhead on large rendering payloads. The renderer still accepts the legacy form
encoding, so a not-yet-upgraded gem keeps working against an upgraded renderer during rolling
deploys; deploy the Node renderer before or together with the gem upgrade, since an older renderer
rejects the new content type. Fixes
[Issue 3584](https://github.com/shakacode/react_on_rails/issues/3584).
[PR 4579](https://github.com/shakacode/react_on_rails/pull/4579) by
[alexeyr-ci2](https://github.com/alexeyr-ci2).

#### Added

- **[Pro] Configurable license-token secret sources**: Rails applications can now provide a paid
Expand Down
23 changes: 10 additions & 13 deletions benchmarks/bench-node-renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ def read_password_from_config
BASE_URL = env_or_default("BASE_URL", "localhost:3800")
PROTOCOL_VERSION = read_protocol_version
LOAD_GENERATOR_SHARDS = env_or_default("LOAD_GENERATOR_SHARDS", 1).to_i
RAW_RENDER_CONTENT_TYPE = "application/vnd.react-on-rails.render-request+javascript"
RAW_RENDER_PROTOCOL_HEADER = "X-React-On-Rails-Pro-Protocol-Version"

# Test cases: JavaScript expressions to evaluate
# Format: { name: "test_name", request: "javascript_code", rsc: true/false }
Expand Down Expand Up @@ -105,8 +107,10 @@ def rsc_bundle?(bundle_timestamp)
# Use curl with h2c since Net::HTTP doesn't support HTTP/2
result, status = Open3.capture2(
"curl", "-s", "--http2-prior-knowledge", "-X", "POST",
"-H", "Content-Type: application/x-www-form-urlencoded",
"-d", body,
"-H", "Content-Type: #{RAW_RENDER_CONTENT_TYPE}",
"-H", "#{RAW_RENDER_PROTOCOL_HEADER}: #{PROTOCOL_VERSION}",
"-H", "Authorization: Bearer #{PASSWORD}",
"--data-binary", body,
url
)
return nil unless status.success?
Expand Down Expand Up @@ -145,23 +149,14 @@ def categorize_bundles(bundles)
[rsc_bundle, non_rsc_bundle]
end

# URL-encode special characters for form body
def url_encode(str)
URI.encode_www_form_component(str)
end

# Build render URL for a bundle and render name
def render_url(bundle_timestamp, render_name)
"http://#{BASE_URL}/bundles/#{bundle_timestamp}/render/#{render_name}"
end

# Build request body for a rendering request
def render_body(rendering_request)
[
"protocolVersion=#{url_encode(PROTOCOL_VERSION)}",
"password=#{url_encode(PASSWORD)}",
"renderingRequest=#{url_encode(rendering_request)}"
].join("&")
rendering_request
end

def validate_node_renderer_benchmark_config!
Expand Down Expand Up @@ -380,7 +375,9 @@ def run_vegeta_benchmark(test_case, bundle_timestamp, shard_count: LOAD_GENERATO
# Write targets file (Vegeta format with @body reference)
File.write(targets_file, <<~TARGETS)
POST #{target_url}
Content-Type: application/x-www-form-urlencoded
Content-Type: #{RAW_RENDER_CONTENT_TYPE}
#{RAW_RENDER_PROTOCOL_HEADER}: #{PROTOCOL_VERSION}
Authorization: Bearer #{PASSWORD}
@#{body_file}
TARGETS

Expand Down
26 changes: 26 additions & 0 deletions benchmarks/spec/bench_node_renderer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,32 @@ def add(name:, rps:, p50:, status:, p90: nil)
end

describe "#run_vegeta_benchmark" do
it "sends the rendering request verbatim using the raw renderer protocol" do
stub_const("CONNECTIONS", 1)
stub_const("MAX_CONNECTIONS", 1)
stub_const("RATE", "max")
stub_const("DURATION", "1s")

writes = {}
allow(File).to receive(:write) { |path, contents| writes[path] = contents }
allow(FileUtils).to receive(:rm_f)
allow(Process).to receive_messages(spawn: 1, wait2: [1, process_status(success: true)])
allow(self).to receive_messages(system: true, parse_json_file: { "throughput" => 1,
"latencies" => { "50th" => 1_000_000,
"90th" => 2_000_000 },
"status_codes" => { "200" => 1 } })

rendering_request = 'render({quoted: "value & more"})'
run_vegeta_benchmark({ name: "raw_request", request: rendering_request }, "bundleX", shard_count: 1)

expect(writes.fetch("#{OUTDIR}/raw_request_vegeta_body.txt")).to eq(rendering_request)
expect(writes.fetch("#{OUTDIR}/raw_request_vegeta_targets.txt")).to include(
"Content-Type: #{RAW_RENDER_CONTENT_TYPE}",
"#{RAW_RENDER_PROTOCOL_HEADER}: #{PROTOCOL_VERSION}",
"Authorization: Bearer #{PASSWORD}"
)
end

it "runs all shards concurrently before merging their result streams into one Vegeta report" do
stub_const("CONNECTIONS", 10)
stub_const("MAX_CONNECTIONS", 10)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ export function smartTrim(value: unknown, maxLength = getConfig().maxDebugSnippe
export interface ResponseResult {
headers: {
'Cache-Control'?: string;
'Content-Type'?: string;
// Content-Type is not caller-settable: setResponse forces text/plain on every string
// payload (reflected-XSS hardening) and otherwise lets Fastify infer it.
'Content-Type'?: never;
'X-Content-Type-Options'?: string;
[key: string]: string | undefined;
};
Expand Down
165 changes: 128 additions & 37 deletions packages/react-on-rails-pro-node-renderer/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,45 +108,27 @@ function setHeaders(headers: ResponseResult['headers'], res: FastifyReply) {
Object.entries(headers).forEach(([key, header]) => res.header(key, header));
}

function hasHeader(headers: ResponseResult['headers'], headerName: string) {
const lowerHeaderName = headerName.toLowerCase();
return Object.keys(headers).some((key) => key.toLowerCase() === lowerHeaderName);
}

function setStringResponseHeaders(headers: ResponseResult['headers'], res: FastifyReply) {
if (!hasHeader(headers, 'Content-Type')) {
res.type('text/plain; charset=utf-8');
}
if (!hasHeader(headers, 'X-Content-Type-Options')) {
res.header('X-Content-Type-Options', 'nosniff');
}
}

const setResponse = async (result: ResponseResult, res: FastifyReply) => {
const { status, data, headers, stream } = result;
if (status !== 200 && status !== 410) {
log.info({ msg: 'Sending non-200, non-410 data back', data });
}
if (!stream && typeof data === 'string' && status >= 400) {
setHeaders(headers, res);
res.header('Content-Type', 'text/plain; charset=utf-8');
res.header('X-Content-Type-Options', 'nosniff');
res.status(status);
res.send(data);
return;
}

if (!stream && typeof data === 'string') {
setStringResponseHeaders(headers, res);
}
setHeaders(headers, res);
res.status(status);

if (stream) {
await res.send(stream);
} else {
res.send(data);
return;
}

if (typeof data === 'string') {
// String payloads (rendered output and error text) may embed request-derived
// content; the Rails client reads them as raw text, so an explicit non-HTML
// content type keeps reflected markup inert if a browser hits this endpoint.
res.header('Content-Type', 'text/plain; charset=utf-8');
res.header('X-Content-Type-Options', 'nosniff');
Comment thread
justin808 marked this conversation as resolved.
}
res.send(data);
};

function runWhenStreamFinishes(
Expand Down Expand Up @@ -399,6 +381,8 @@ export const disableHttp2 = () => {
type WithBodyArrayField<T, K extends string> = T & { [P in K | `${K}[]`]?: string | string[] };

const INVALID_CONTENT_LENGTH_ERROR_CODE = 'FST_ERR_CTP_INVALID_CONTENT_LENGTH';
const RAW_RENDER_CONTENT_TYPE = 'application/vnd.react-on-rails.render-request+javascript';
const RAW_RENDER_HEADER_PREFIX = 'x-react-on-rails-pro-';

const errorCode = (error: unknown): string | undefined => {
const code = (error as { code?: unknown })?.code;
Expand Down Expand Up @@ -459,6 +443,89 @@ const extractBodyArrayField = <Key extends string>(
return undefined;
};

type RawRenderHeaders = Record<string, unknown>;

const scalarHeader = (headers: RawRenderHeaders, name: string) => {
const value = headers[name];
return typeof value === 'string' ? value : undefined;
};

const normalizeRawRenderRequest = (
renderingRequest: unknown,
headers: RawRenderHeaders,
): { body?: Record<string, unknown>; error?: string } => {
if (typeof renderingRequest !== 'string') {
return { error: 'Invalid raw render request body: expected a JavaScript string.' };
}

const authorization = scalarHeader(headers, 'authorization');
if (authorization && !authorization.startsWith('Bearer ')) {
return { error: 'Invalid Authorization header for raw render request.' };
}

const dependencyHeader = scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}dependency-bundle-timestamps`);
let dependencyBundleTimestamps: unknown = [];
if (dependencyHeader !== undefined) {
try {
dependencyBundleTimestamps = JSON.parse(dependencyHeader);
} catch {
return { error: 'Invalid dependency bundle timestamps header: expected a JSON array.' };
}
if (
!Array.isArray(dependencyBundleTimestamps) ||
!dependencyBundleTimestamps.every((timestamp) => typeof timestamp === 'string')
) {
return { error: 'Invalid dependency bundle timestamps header: expected an array of strings.' };
}
}

const observabilityHeader = scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}rsc-stream-observability`);
if (
observabilityHeader !== undefined &&
observabilityHeader !== 'true' &&
observabilityHeader !== 'false'
) {
return { error: 'Invalid RSC stream observability header: expected true or false.' };
}

return {
body: {
renderingRequest,
protocolVersion: scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}protocol-version`),
gemVersion: scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}gem-version`),
railsEnv: scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}rails-env`),
password: authorization?.slice('Bearer '.length),
dependencyBundleTimestamps,
...(observabilityHeader === undefined ? {} : { rscStreamObservability: observabilityHeader }),
},
};
};

// Extracts only the precheck fields, without shape validation, so protocol, version, and
// password checks run before normalizeRawRenderRequest's 400s; a malformed but
// unauthenticated raw request must fail auth first, matching the parsed-body path and
// /asset-exists. Must include every field checkProtocolVersion reads (gemVersion and
// railsEnv drive the non-production version-mismatch 412), since prechecks run only once
// on this path. Absent headers must not produce keys at all, so the protocol-version
// diagnostic's "received fields" list only names fields the client actually sent.
const rawRenderPrecheckBody = (headers: RawRenderHeaders) => {
const authorization = scalarHeader(headers, 'authorization');
const body: Record<string, string> = {};
const assign = (key: string, value: string | undefined) => {
if (value !== undefined) {
body[key] = value;
}
};
assign('protocolVersion', scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}protocol-version`));
assign('gemVersion', scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}gem-version`));
assign('railsEnv', scalarHeader(headers, `${RAW_RENDER_HEADER_PREFIX}rails-env`));
assign(
'password',
authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : authorization,
);
return body;
};
Comment thread
justin808 marked this conversation as resolved.

function discardMultipartFile(part: MultipartFile) {
part.file.resume();
// eslint-disable-next-line no-param-reassign
Expand Down Expand Up @@ -534,7 +601,10 @@ export default function run(config: Partial<Config>) {
}
});

// Supports application/x-www-form-urlencoded
// Supports application/x-www-form-urlencoded. The gem no longer sends it (render
// requests use RAW_RENDER_CONTENT_TYPE), but keep parsing it so a not-yet-upgraded
// gem in a rolling deploy still reaches the protocol/render path instead of failing
// with an unactionable 415 at the content-type layer.
void app.register(fastifyFormbody);
// Supports multipart/form-data
void app.register(fastifyMultipart, {
Expand Down Expand Up @@ -584,6 +654,10 @@ export default function run(config: Partial<Config>) {
},
});

app.addContentTypeParser(RAW_RENDER_CONTENT_TYPE, { parseAs: 'string' }, (_req, body, done) => {
Comment thread
justin808 marked this conversation as resolved.
done(null, body);
});

// Ensure NDJSON bodies are not buffered and are available as a stream immediately
app.addContentTypeParser('application/x-ndjson', (req, payload, done) => {
// Pass through the raw stream; the route will consume req.raw
Expand All @@ -598,9 +672,31 @@ export default function run(config: Partial<Config>) {
// Can't infer from the route like Express can
Params: { bundleTimestamp: string; renderRequestDigest: string };
}>('/bundles/:bundleTimestamp/render/:renderRequestDigest', async (req, res) => {
const precheckResult = performRequestPrechecks(req.body);
if (precheckResult) {
await setResponse(precheckResult, res);
let body: Record<string, unknown>;
if (req.headers['content-type']?.split(';', 1)[0] === RAW_RENDER_CONTENT_TYPE) {
const precheckResult = performRequestPrechecks(rawRenderPrecheckBody(req.headers));
Comment thread
justin808 marked this conversation as resolved.
if (precheckResult) {
await setResponse(precheckResult, res);
return;
}
const normalizedRequest = normalizeRawRenderRequest(req.body, req.headers);
if (normalizedRequest.error || !normalizedRequest.body) {
await setResponse(
badRequestResponseResult(normalizedRequest.error ?? 'Invalid raw render request.'),
res,
);
return;
}
body = normalizedRequest.body;
} else if (req.body && typeof req.body === 'object') {
body = req.body;
const precheckResult = performRequestPrechecks(body);
if (precheckResult) {
await setResponse(precheckResult, res);
return;
}
} else {
await setResponse(badRequestResponseResult('Invalid or missing request body.'), res);
return;
}
Comment thread
justin808 marked this conversation as resolved.

Expand All @@ -615,11 +711,6 @@ export default function run(config: Partial<Config>) {
// await delay(100000);
// }

const { body } = req;
if (!body || typeof body !== 'object') {
await setResponse(badRequestResponseResult('Invalid or missing request body.'), res);
return;
}
const { renderingRequest } = body;
if (!isValidRenderingRequest(renderingRequest)) {
await setResponse(badRequestResponseResult(invalidRenderingRequestMessage(body)), res);
Expand Down
Loading
Loading