diff --git a/knip.ts b/knip.ts index 29af90d608..12801f717a 100644 --- a/knip.ts +++ b/knip.ts @@ -75,6 +75,7 @@ const config: KnipConfig = { 'tests/emptyForTesting.js', // Jest setup and test utilities - not detected by Jest plugin in workspace setup 'tests/jest.setup.js', + 'tests/utils/removeRSCStackFromAllChunks.ts', // Build output directories that should be ignored 'lib/**', // Pro features exported for external consumption diff --git a/packages/react-on-rails-pro/package.json b/packages/react-on-rails-pro/package.json index c09a9ef077..3fb75bbf50 100644 --- a/packages/react-on-rails-pro/package.json +++ b/packages/react-on-rails-pro/package.json @@ -75,5 +75,9 @@ "bugs": { "url": "https://github.com/shakacode/react_on_rails/issues" }, - "homepage": "https://github.com/shakacode/react_on_rails#readme" + "homepage": "https://github.com/shakacode/react_on_rails#readme", + "devDependencies": { + "@types/mock-fs": "^4.13.4", + "mock-fs": "^5.5.0" + } } diff --git a/packages/react-on-rails-pro/tests/AsyncQueue.ts b/packages/react-on-rails-pro/tests/AsyncQueue.ts new file mode 100644 index 0000000000..d6f8b7b204 --- /dev/null +++ b/packages/react-on-rails-pro/tests/AsyncQueue.ts @@ -0,0 +1,57 @@ +import * as EventEmitter from 'node:events'; + +class AsyncQueue { + private eventEmitter = new EventEmitter(); + + private buffer: T[] = []; + + private isEnded = false; + + enqueue(value: T) { + if (this.isEnded) { + throw new Error('Queue Ended'); + } + + if (this.eventEmitter.listenerCount('data') > 0) { + this.eventEmitter.emit('data', value); + } else { + this.buffer.push(value); + } + } + + end() { + this.isEnded = true; + this.eventEmitter.emit('end'); + } + + dequeue() { + return new Promise((resolve, reject) => { + const bufferValueIfExist = this.buffer.shift(); + if (bufferValueIfExist) { + resolve(bufferValueIfExist); + } else if (this.isEnded) { + reject(new Error('Queue Ended')); + } else { + let teardown = () => {}; + const onData = (value: T) => { + resolve(value); + teardown(); + }; + + const onEnd = () => { + reject(new Error('Queue Ended')); + teardown(); + }; + + this.eventEmitter.on('data', onData); + this.eventEmitter.on('end', onEnd); + teardown = () => { + this.eventEmitter.off('data', onData); + this.eventEmitter.off('end', onEnd); + }; + } + }); + } +} + +export default AsyncQueue; diff --git a/packages/react-on-rails-pro/tests/StreamReader.ts b/packages/react-on-rails-pro/tests/StreamReader.ts new file mode 100644 index 0000000000..86ee5195b4 --- /dev/null +++ b/packages/react-on-rails-pro/tests/StreamReader.ts @@ -0,0 +1,33 @@ +import { PassThrough, Readable } from 'node:stream'; +import AsyncQueue from './AsyncQueue.ts'; + +class StreamReader { + private asyncQueue: AsyncQueue; + + constructor(pipeableStream: Pick) { + this.asyncQueue = new AsyncQueue(); + const decoder = new TextDecoder(); + + const readableStream = new PassThrough(); + pipeableStream.pipe(readableStream); + + readableStream.on('data', (chunk: Buffer) => { + const decodedChunk = decoder.decode(chunk); + this.asyncQueue.enqueue(decodedChunk); + }); + + if (readableStream.closed) { + this.asyncQueue.end(); + } else { + readableStream.on('end', () => { + this.asyncQueue.end(); + }); + } + } + + nextChunk() { + return this.asyncQueue.dequeue(); + } +} + +export default StreamReader; diff --git a/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx b/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx new file mode 100644 index 0000000000..353b19d6f8 --- /dev/null +++ b/packages/react-on-rails-pro/tests/concurrentRSCPayloadGeneration.rsc.test.tsx @@ -0,0 +1,144 @@ +/** + * @jest-environment node + */ +/// + +import * as React from 'react'; +import { Suspense, PropsWithChildren } from 'react'; + +import * as path from 'path'; +import * as mock from 'mock-fs'; + +import ReactOnRails, { RailsContextWithServerStreamingCapabilities } from '../src/ReactOnRailsRSC.ts'; +import AsyncQueue from './AsyncQueue.ts'; +import StreamReader from './StreamReader.ts'; +import removeRSCChunkStack from './utils/removeRSCChunkStack.ts'; + +const manifestFileDirectory = path.resolve(__dirname, '../src'); +const clientManifestPath = path.join(manifestFileDirectory, 'react-client-manifest.json'); + +beforeEach(() => { + mock({ + [clientManifestPath]: JSON.stringify({ + filePathToModuleMetadata: {}, + moduleLoading: { prefix: '', crossOrigin: null }, + }), + }); +}); + +afterEach(() => mock.restore()); + +const AsyncQueueItem = async ({ + asyncQueue, + children, +}: PropsWithChildren<{ asyncQueue: AsyncQueue }>) => { + const value = await asyncQueue.dequeue(); + + return ( + <> +

Data: {value}

+ {children} + + ); +}; + +const AsyncQueueContainer = ({ asyncQueue }: { asyncQueue: AsyncQueue }) => { + return ( +
+

Async Queue

+ Loading Item1

}> + + Loading Item2

}> + + Loading Item3

}> + +
+
+
+
+
+
+ ); +}; + +ReactOnRails.register({ AsyncQueueContainer }); + +const renderComponent = (props: Record) => { + return ReactOnRails.serverRenderRSCReactComponent({ + railsContext: { + reactClientManifestFileName: 'react-client-manifest.json', + reactServerClientManifestFileName: 'react-server-client-manifest.json', + } as unknown as RailsContextWithServerStreamingCapabilities, + name: 'AsyncQueueContainer', + renderingReturnsPromises: true, + throwJsErrors: true, + domNodeId: 'dom-id', + props, + }); +}; + +const createParallelRenders = (size: number) => { + const asyncQueues = new Array(size).fill(null).map(() => new AsyncQueue()); + const streams = asyncQueues.map((asyncQueue) => { + return renderComponent({ asyncQueue }); + }); + const readers = streams.map((stream) => new StreamReader(stream)); + + const enqueue = (value: string) => asyncQueues.forEach((asyncQueue) => asyncQueue.enqueue(value)); + + const expectNextChunk = (nextChunk: string) => + Promise.all( + readers.map(async (reader) => { + const chunk = await reader.nextChunk(); + expect(removeRSCChunkStack(chunk)).toEqual(removeRSCChunkStack(nextChunk)); + }), + ); + + const expectEndOfStream = () => + Promise.all(readers.map((reader) => expect(reader.nextChunk()).rejects.toThrow(/Queue Ended/))); + + return { enqueue, expectNextChunk, expectEndOfStream }; +}; + +test('Renders concurrent rsc streams as single rsc stream', async () => { + expect.assertions(258); + const asyncQueue = new AsyncQueue(); + const stream = renderComponent({ asyncQueue }); + const reader = new StreamReader(stream); + + const chunks: string[] = []; + let chunk = await reader.nextChunk(); + chunks.push(chunk); + expect(chunk).toContain('Async Queue'); + expect(chunk).toContain('Loading Item2'); + expect(chunk).not.toContain('Random Value'); + + asyncQueue.enqueue('Random Value1'); + chunk = await reader.nextChunk(); + chunks.push(chunk); + expect(chunk).toContain('Random Value1'); + + asyncQueue.enqueue('Random Value2'); + chunk = await reader.nextChunk(); + chunks.push(chunk); + expect(chunk).toContain('Random Value2'); + + asyncQueue.enqueue('Random Value3'); + chunk = await reader.nextChunk(); + chunks.push(chunk); + expect(chunk).toContain('Random Value3'); + + await expect(reader.nextChunk()).rejects.toThrow(/Queue Ended/); + + const { enqueue, expectNextChunk, expectEndOfStream } = createParallelRenders(50); + + expect(chunks).toHaveLength(4); + await expectNextChunk(chunks[0]); + enqueue('Random Value1'); + await expectNextChunk(chunks[1]); + enqueue('Random Value2'); + await expectNextChunk(chunks[2]); + enqueue('Random Value3'); + await expectNextChunk(chunks[3]); + await expectEndOfStream(); +}); diff --git a/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx b/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx new file mode 100644 index 0000000000..4053f8280e --- /dev/null +++ b/packages/react-on-rails-pro/tests/serverRenderRSCReactComponent.rsc.test.tsx @@ -0,0 +1,160 @@ +/** + * @jest-environment node + */ +/// + +import * as React from 'react'; +import { Suspense } from 'react'; +import * as mock from 'mock-fs'; +import * as path from 'path'; +import { finished } from 'stream/promises'; +import { text } from 'stream/consumers'; +import ReactOnRails, { RailsContextWithServerStreamingCapabilities } from '../src/ReactOnRailsRSC.ts'; + +const PromiseWrapper = async ({ promise, name }: { promise: Promise; name: string }) => { + console.log(`[${name}] Before awaitng`); + const value = await promise; + console.log(`[${name}] After awaitng`); + return

Value: {value}

; +}; + +const PromiseContainer = ({ name }: { name: string }) => { + const promise = new Promise((resolve) => { + let i = 0; + const intervalId = setInterval(() => { + console.log(`Interval ${i} at [${name}]`); + i += 1; + if (i === 50) { + clearInterval(intervalId); + resolve(`Value of name ${name}`); + } + }, 1); + }); + + return ( +
+

Initial Header

+ Loading Promise

}> + +
+
+ ); +}; + +ReactOnRails.register({ PromiseContainer }); + +const manifestFileDirectory = path.resolve(__dirname, '../src'); +const clientManifestPath = path.join(manifestFileDirectory, 'react-client-manifest.json'); + +beforeEach(() => { + mock({ + [clientManifestPath]: JSON.stringify({ + filePathToModuleMetadata: {}, + moduleLoading: { prefix: '', crossOrigin: null }, + }), + }); +}); + +afterEach(() => { + mock.restore(); +}); + +test('no logs leakage between concurrent rendering components', async () => { + const readable1 = ReactOnRails.serverRenderRSCReactComponent({ + railsContext: { + reactClientManifestFileName: 'react-client-manifest.json', + reactServerClientManifestFileName: 'react-server-client-manifest.json', + } as unknown as RailsContextWithServerStreamingCapabilities, + name: 'PromiseContainer', + renderingReturnsPromises: true, + throwJsErrors: true, + domNodeId: 'dom-id', + props: { name: 'First Unique Name' }, + }); + const readable2 = ReactOnRails.serverRenderRSCReactComponent({ + railsContext: { + reactClientManifestFileName: 'react-client-manifest.json', + reactServerClientManifestFileName: 'react-server-client-manifest.json', + } as unknown as RailsContextWithServerStreamingCapabilities, + name: 'PromiseContainer', + renderingReturnsPromises: true, + throwJsErrors: true, + domNodeId: 'dom-id', + props: { name: 'Second Unique Name' }, + }); + + const [content1, content2] = await Promise.all([text(readable1), text(readable2)]); + + expect(content1).toContain('First Unique Name'); + expect(content2).toContain('Second Unique Name'); + expect(content1).not.toContain('Second Unique Name'); + expect(content2).not.toContain('First Unique Name'); +}); + +test('no logs lekage from outside the component', async () => { + const readable1 = ReactOnRails.serverRenderRSCReactComponent({ + railsContext: { + reactClientManifestFileName: 'react-client-manifest.json', + reactServerClientManifestFileName: 'react-server-client-manifest.json', + } as unknown as RailsContextWithServerStreamingCapabilities, + name: 'PromiseContainer', + renderingReturnsPromises: true, + throwJsErrors: true, + domNodeId: 'dom-id', + props: { name: 'First Unique Name' }, + }); + + const promise = new Promise((resolve) => { + let i = 0; + const intervalId = setInterval(() => { + console.log(`Interval ${i} at [Outside The Component]`); + i += 1; + if (i === 50) { + clearInterval(intervalId); + resolve(); + } + }, 1); + }); + + const [content1] = await Promise.all([text(readable1), promise]); + + expect(content1).toContain('First Unique Name'); + expect(content1).not.toContain('Outside The Component'); +}); + +test('[bug] catches logs outside the component during reading the stream', async () => { + const readable1 = ReactOnRails.serverRenderRSCReactComponent({ + railsContext: { + reactClientManifestFileName: 'react-client-manifest.json', + reactServerClientManifestFileName: 'react-server-client-manifest.json', + } as unknown as RailsContextWithServerStreamingCapabilities, + name: 'PromiseContainer', + renderingReturnsPromises: true, + throwJsErrors: true, + domNodeId: 'dom-id', + props: { name: 'First Unique Name' }, + }); + + let content1 = ''; + let i = 0; + readable1.on('data', (chunk: Buffer) => { + i += 1; + // To avoid infinite loop + if (i < 5) { + console.log('Outside The Component'); + } + content1 += chunk.toString(); + }); + + // However, any logs from outside the stream 'data' event callback is not catched + const intervalId = setInterval(() => { + console.log('From Interval'); + }, 2); + await finished(readable1); + clearInterval(intervalId); + + expect(content1).toContain('First Unique Name'); + expect(content1).not.toContain('From Interval'); + // Here's the bug + expect(content1).toContain('Outside The Component'); +}); diff --git a/packages/react-on-rails-pro/tests/utils/removeRSCChunkStack.ts b/packages/react-on-rails-pro/tests/utils/removeRSCChunkStack.ts new file mode 100644 index 0000000000..4774feadfa --- /dev/null +++ b/packages/react-on-rails-pro/tests/utils/removeRSCChunkStack.ts @@ -0,0 +1,28 @@ +import { RSCPayloadChunk } from 'react-on-rails'; + +const removeRSCChunkStack = (chunk: string) => { + const parsedJson = JSON.parse(chunk) as RSCPayloadChunk; + const { html } = parsedJson; + const santizedHtml = html.split('\n').map((chunkLine) => { + if (!chunkLine.includes('"stack":')) { + return chunkLine; + } + + const regexMatch = /(^\d+):\{/.exec(chunkLine); + if (!regexMatch) { + return chunkLine; + } + + const chunkJsonString = chunkLine.slice(chunkLine.indexOf('{')); + const chunkJson = JSON.parse(chunkJsonString) as { stack?: string }; + delete chunkJson.stack; + return `${regexMatch[1]}:${JSON.stringify(chunkJson)}`; + }); + + return JSON.stringify({ + ...parsedJson, + html: santizedHtml, + }); +}; + +export default removeRSCChunkStack; diff --git a/packages/react-on-rails-pro/tests/utils/removeRSCStackFromAllChunks.ts b/packages/react-on-rails-pro/tests/utils/removeRSCStackFromAllChunks.ts new file mode 100644 index 0000000000..717a10aee8 --- /dev/null +++ b/packages/react-on-rails-pro/tests/utils/removeRSCStackFromAllChunks.ts @@ -0,0 +1,10 @@ +import removeRSCChunkStack from './removeRSCChunkStack.ts'; + +const removeRSCStackFromAllChunks = (allChunks: string) => { + return allChunks + .split('\n') + .map((chunk) => (chunk.trim().length > 0 ? removeRSCChunkStack(chunk) : chunk)) + .join('\n'); +}; + +export default removeRSCStackFromAllChunks; diff --git a/packages/react-on-rails/src/types/index.ts b/packages/react-on-rails/src/types/index.ts index 20891eb2a2..c1fc46518a 100644 --- a/packages/react-on-rails/src/types/index.ts +++ b/packages/react-on-rails/src/types/index.ts @@ -225,7 +225,6 @@ export interface RenderParams extends Params { export interface RSCRenderParams extends Omit { railsContext: RailsContextWithServerStreamingCapabilities; - reactClientManifestFileName: string; } export interface CreateParams extends Params { diff --git a/react_on_rails_pro/package.json b/react_on_rails_pro/package.json index db05cccff1..26cb4705e2 100644 --- a/react_on_rails_pro/package.json +++ b/react_on_rails_pro/package.json @@ -72,6 +72,7 @@ "jest-junit": "^16.0.0", "jsdom": "^16.5.0", "ndb": "^1.1.5", + "node-html-parser": "^7.0.1", "nps": "^5.9.12", "pino-pretty": "^13.0.0", "prettier": "^3.2.5", diff --git a/react_on_rails_pro/packages/node-renderer/tests/concurrentHtmlStreaming.test.ts b/react_on_rails_pro/packages/node-renderer/tests/concurrentHtmlStreaming.test.ts new file mode 100644 index 0000000000..e3dc5a3500 --- /dev/null +++ b/react_on_rails_pro/packages/node-renderer/tests/concurrentHtmlStreaming.test.ts @@ -0,0 +1,146 @@ +import { randomUUID } from 'crypto'; +import { createClient } from 'redis'; +import parser from 'node-html-parser'; + +// @ts-expect-error TODO: fix later +import { RSCPayloadChunk } from 'react-on-rails'; +import buildApp from '../src/worker'; +import config from './testingNodeRendererConfigs'; +import { makeRequest } from './httpRequestUtils'; +import { Config } from '../src/shared/configBuilder'; + +const app = buildApp(config as Partial); +const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; +const redisClient = createClient({ url: redisUrl }); + +beforeAll(async () => { + await redisClient.connect(); + await app.ready(); + await app.listen({ port: 0 }); +}); + +afterAll(async () => { + await app.close(); + await redisClient.close(); +}); + +const sendRedisValue = async (redisRequestId: string, key: string, value: string) => { + await redisClient.xAdd(`stream:${redisRequestId}`, '*', { [`:${key}`]: JSON.stringify(value) }); +}; + +const sendRedisItemValue = async (redisRequestId: string, itemIndex: number, value: string) => { + await sendRedisValue(redisRequestId, `Item${itemIndex}`, value); +}; + +const extractHtmlFromChunks = (chunks: string) => { + const html = chunks + .split('\n') + .map((chunk) => (chunk.trim().length > 0 ? (JSON.parse(chunk) as RSCPayloadChunk).html : chunk)) + .join(''); + const parsedHtml = parser.parse(html); + // TODO: investigate why ReactOnRails produces different RSC payload on each request + parsedHtml.querySelectorAll('script').forEach((x) => x.remove()); + const sanitizedHtml = parsedHtml.toString(); + return sanitizedHtml; +}; + +const createParallelRenders = (size: number) => { + const redisRequestIds = Array(size) + .fill(null) + .map(() => randomUUID()); + const renderRequests = redisRequestIds.map((redisRequestId) => { + return makeRequest(app, { + componentName: 'RedisReceiver', + props: { requestId: redisRequestId }, + }); + }); + + const expectNextChunk = async (expectedNextChunk: string) => { + const nextChunks = await Promise.all( + renderRequests.map((renderRequest) => renderRequest.waitForNextChunk()), + ); + nextChunks.forEach((chunk, index) => { + const redisRequestId = redisRequestIds[index]!; + const chunksAfterRemovingRequestId = chunk.replace(new RegExp(redisRequestId, 'g'), ''); + expect(extractHtmlFromChunks(chunksAfterRemovingRequestId)).toEqual( + extractHtmlFromChunks(expectedNextChunk), + ); + }); + }; + + const sendRedisItemValues = async (itemIndex: number, itemValue: string) => { + await Promise.all( + redisRequestIds.map((redisRequestId) => sendRedisItemValue(redisRequestId, itemIndex, itemValue)), + ); + }; + + const waitUntilFinished = async () => { + await Promise.all(renderRequests.map((renderRequest) => renderRequest.finishedPromise)); + renderRequests.forEach((renderRequest) => { + expect(renderRequest.getBuffer()).toHaveLength(0); + }); + }; + + return { + expectNextChunk, + sendRedisItemValues, + waitUntilFinished, + }; +}; + +test('Happy Path', async () => { + const parallelInstances = 50; + expect.assertions(parallelInstances * 7 + 7); + const redisRequestId = randomUUID(); + const { waitForNextChunk, finishedPromise, getBuffer } = makeRequest(app, { + componentName: 'RedisReceiver', + props: { requestId: redisRequestId }, + }); + const chunks: string[] = []; + let chunk = await waitForNextChunk(); + expect(chunk).not.toContain('Unique Value'); + chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), '')); + + await sendRedisItemValue(redisRequestId, 0, 'First Unique Value'); + chunk = await waitForNextChunk(); + expect(chunk).toContain('First Unique Value'); + chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), '')); + + await sendRedisItemValue(redisRequestId, 4, 'Fifth Unique Value'); + chunk = await waitForNextChunk(); + expect(chunk).toContain('Fifth Unique Value'); + chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), '')); + + await sendRedisItemValue(redisRequestId, 2, 'Third Unique Value'); + chunk = await waitForNextChunk(); + expect(chunk).toContain('Third Unique Value'); + chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), '')); + + await sendRedisItemValue(redisRequestId, 1, 'Second Unique Value'); + chunk = await waitForNextChunk(); + expect(chunk).toContain('Second Unique Value'); + chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), '')); + + await sendRedisItemValue(redisRequestId, 3, 'Forth Unique Value'); + chunk = await waitForNextChunk(); + expect(chunk).toContain('Forth Unique Value'); + chunks.push(chunk.replace(new RegExp(redisRequestId, 'g'), '')); + + await finishedPromise; + expect(getBuffer).toHaveLength(0); + + const { expectNextChunk, sendRedisItemValues, waitUntilFinished } = + createParallelRenders(parallelInstances); + await expectNextChunk(chunks[0]!); + await sendRedisItemValues(0, 'First Unique Value'); + await expectNextChunk(chunks[1]!); + await sendRedisItemValues(4, 'Fifth Unique Value'); + await expectNextChunk(chunks[2]!); + await sendRedisItemValues(2, 'Third Unique Value'); + await expectNextChunk(chunks[3]!); + await sendRedisItemValues(1, 'Second Unique Value'); + await expectNextChunk(chunks[4]!); + await sendRedisItemValues(3, 'Forth Unique Value'); + await expectNextChunk(chunks[5]!); + await waitUntilFinished(); +}, 50000); diff --git a/react_on_rails_pro/packages/node-renderer/tests/htmlStreaming.test.js b/react_on_rails_pro/packages/node-renderer/tests/htmlStreaming.test.js index b09046de38..cd59220570 100644 --- a/react_on_rails_pro/packages/node-renderer/tests/htmlStreaming.test.js +++ b/react_on_rails_pro/packages/node-renderer/tests/htmlStreaming.test.js @@ -1,12 +1,8 @@ -import fs from 'fs'; import http2 from 'http2'; -import path from 'path'; -import FormData from 'form-data'; import buildApp from '../src/worker'; import config from './testingNodeRendererConfigs'; -import { readRenderingRequest } from './helper'; import * as errorReporter from '../src/shared/errorReporter'; -import packageJson from '../src/shared/packageJson'; +import { createForm, SERVER_BUNDLE_TIMESTAMP } from './httpRequestUtils'; const app = buildApp(config); @@ -21,54 +17,6 @@ afterAll(async () => { jest.spyOn(errorReporter, 'message').mockImplementation(jest.fn()); -const SERVER_BUNDLE_TIMESTAMP = '77777-test'; -// Ensure to match the rscBundleHash at `asyncComponentsTreeForTestingRenderingRequest.js` fixture -const RSC_BUNDLE_TIMESTAMP = '88888-test'; - -const createForm = ({ project = 'spec-dummy', commit = '', props = {}, throwJsErrors = false } = {}) => { - const form = new FormData(); - form.append('gemVersion', packageJson.version); - form.append('protocolVersion', packageJson.protocolVersion); - form.append('password', 'myPassword1'); - form.append('dependencyBundleTimestamps[]', RSC_BUNDLE_TIMESTAMP); - - let renderingRequestCode = readRenderingRequest( - project, - commit, - 'asyncComponentsTreeForTestingRenderingRequest.js', - ); - renderingRequestCode = renderingRequestCode.replace(/\(\s*\)\s*$/, `(undefined, ${JSON.stringify(props)})`); - if (throwJsErrors) { - renderingRequestCode = renderingRequestCode.replace('throwJsErrors: false', 'throwJsErrors: true'); - } - form.append('renderingRequest', renderingRequestCode); - - const testBundlesDirectory = path.join(__dirname, '../../../spec/dummy/ssr-generated'); - const testClientBundlesDirectory = path.join(__dirname, '../../../spec/dummy/public/webpack/test'); - const bundlePath = path.join(testBundlesDirectory, 'server-bundle.js'); - form.append(`bundle_${SERVER_BUNDLE_TIMESTAMP}`, fs.createReadStream(bundlePath), { - contentType: 'text/javascript', - filename: 'server-bundle.js', - }); - const rscBundlePath = path.join(testBundlesDirectory, 'rsc-bundle.js'); - form.append(`bundle_${RSC_BUNDLE_TIMESTAMP}`, fs.createReadStream(rscBundlePath), { - contentType: 'text/javascript', - filename: 'rsc-bundle.js', - }); - const clientManifestPath = path.join(testClientBundlesDirectory, 'react-client-manifest.json'); - form.append('asset1', fs.createReadStream(clientManifestPath), { - contentType: 'application/json', - filename: 'react-client-manifest.json', - }); - const reactServerClientManifestPath = path.join(testBundlesDirectory, 'react-server-client-manifest.json'); - form.append('asset2', fs.createReadStream(reactServerClientManifestPath), { - contentType: 'application/json', - filename: 'react-server-client-manifest.json', - }); - - return form; -}; - const makeRequest = async (options = {}) => { const startTime = Date.now(); const form = createForm(options); diff --git a/react_on_rails_pro/packages/node-renderer/tests/httpRequestUtils.ts b/react_on_rails_pro/packages/node-renderer/tests/httpRequestUtils.ts new file mode 100644 index 0000000000..7baab23a2e --- /dev/null +++ b/react_on_rails_pro/packages/node-renderer/tests/httpRequestUtils.ts @@ -0,0 +1,176 @@ +import fs from 'fs'; +import path from 'path'; +import http2 from 'http2'; +import FormData from 'form-data'; +import buildApp from '../src/worker'; +import { readRenderingRequest } from './helper'; +import packageJson from '../src/shared/packageJson'; + +export const SERVER_BUNDLE_TIMESTAMP = '77777-test'; +// Ensure to match the rscBundleHash at `asyncComponentsTreeForTestingRenderingRequest.js` fixture +export const RSC_BUNDLE_TIMESTAMP = '88888-test'; + +type RequestOptions = { + project: string; + commit: string; + props: Record; + throwJsErrors: boolean; + componentName: string; + renderRscPayload: boolean; +}; + +export const createForm = ({ + project = 'spec-dummy', + commit = '', + props = {}, + throwJsErrors = false, + componentName = undefined, +}: Partial = {}) => { + const form = new FormData(); + form.append('gemVersion', packageJson.version); + form.append('protocolVersion', packageJson.protocolVersion); + form.append('password', 'myPassword1'); + form.append('dependencyBundleTimestamps[]', RSC_BUNDLE_TIMESTAMP); + + let renderingRequestCode = readRenderingRequest( + project, + commit, + 'asyncComponentsTreeForTestingRenderingRequest.js', + ); + const componentNameString = componentName ? `'${componentName}'` : String(undefined); + renderingRequestCode = renderingRequestCode.replace( + /\(\s*\)\s*$/, + `(${componentNameString}, ${JSON.stringify(props)})`, + ); + if (throwJsErrors) { + renderingRequestCode = renderingRequestCode.replace('throwJsErrors: false', 'throwJsErrors: true'); + } + form.append('renderingRequest', renderingRequestCode); + + const testBundlesDirectory = path.join(__dirname, '../../../spec/dummy/ssr-generated'); + const testClientBundlesDirectory = path.join(__dirname, '../../../spec/dummy/public/webpack/test'); + const bundlePath = path.join(testBundlesDirectory, 'server-bundle.js'); + form.append(`bundle_${SERVER_BUNDLE_TIMESTAMP}`, fs.createReadStream(bundlePath), { + contentType: 'text/javascript', + filename: 'server-bundle.js', + }); + const rscBundlePath = path.join(testBundlesDirectory, 'rsc-bundle.js'); + form.append(`bundle_${RSC_BUNDLE_TIMESTAMP}`, fs.createReadStream(rscBundlePath), { + contentType: 'text/javascript', + filename: 'rsc-bundle.js', + }); + const clientManifestPath = path.join(testClientBundlesDirectory, 'react-client-manifest.json'); + form.append('asset1', fs.createReadStream(clientManifestPath), { + contentType: 'application/json', + filename: 'react-client-manifest.json', + }); + const reactServerClientManifestPath = path.join(testBundlesDirectory, 'react-server-client-manifest.json'); + form.append('asset2', fs.createReadStream(reactServerClientManifestPath), { + contentType: 'application/json', + filename: 'react-server-client-manifest.json', + }); + + return form; +}; + +const getAppUrl = (app: ReturnType) => { + const addresssInfo = app.server.address(); + if (!addresssInfo) { + throw new Error('The app has no address, ensure to run the app before running tests'); + } + + if (typeof addresssInfo === 'string') { + return addresssInfo; + } + + return `http://localhost:${addresssInfo.port}`; +}; + +export const makeRequest = (app: ReturnType, options: Partial = {}) => { + const form = createForm(options); + const client = http2.connect(getAppUrl(app)); + const usedBundleTimestamp = options.renderRscPayload ? RSC_BUNDLE_TIMESTAMP : SERVER_BUNDLE_TIMESTAMP; + const request = client.request({ + ':method': 'POST', + ':path': `/bundles/${usedBundleTimestamp}/render/454a82526211afdb215352755d36032c`, + 'content-type': `multipart/form-data; boundary=${form.getBoundary()}`, + }); + request.setEncoding('utf8'); + + const buffer: string[] = []; + + const statusPromise = new Promise((resolve) => { + request.on('response', (headers) => { + resolve(headers[':status']); + }); + }); + + let resolveChunksPromise: ((chunks: string) => void) | undefined; + let rejectChunksPromise: ((error: unknown) => void) | undefined; + let resolveChunkPromiseTimeout: NodeJS.Timeout | undefined; + + const scheduleResolveChunkPromise = () => { + if (resolveChunkPromiseTimeout) { + clearTimeout(resolveChunkPromiseTimeout); + } + + resolveChunkPromiseTimeout = setTimeout(() => { + resolveChunksPromise?.(buffer.join('')); + resolveChunksPromise = undefined; + rejectChunksPromise = undefined; + buffer.length = 0; + }, 1000); + }; + + request.on('data', (data: Buffer) => { + buffer.push(data.toString()); + if (resolveChunksPromise) { + scheduleResolveChunkPromise(); + } + }); + + form.pipe(request); + form.on('end', () => { + request.end(); + }); + + const rejectPendingChunkPromise = () => { + if (rejectChunksPromise && buffer.length === 0) { + rejectChunksPromise('Request already eneded'); + } + }; + + const finishedPromise = new Promise((resolve, reject) => { + request.on('end', () => { + client.destroy(); + resolve(); + rejectPendingChunkPromise(); + }); + request.on('error', (err) => { + client.destroy(); + reject(err instanceof Error ? err : new Error(String(err))); + rejectPendingChunkPromise(); + }); + }); + + const waitForNextChunk = () => + new Promise((resolve, reject) => { + if (client.closed && buffer.length === 0) { + reject(new Error('Request already eneded')); + } + resolveChunksPromise = resolve; + rejectChunksPromise = reject; + if (buffer.length > 0) { + scheduleResolveChunkPromise(); + } + }); + + const getBuffer = () => [...buffer]; + + return { + statusPromise, + finishedPromise, + waitForNextChunk, + getBuffer, + }; +}; diff --git a/react_on_rails_pro/yarn.lock b/react_on_rails_pro/yarn.lock index 821d238200..0bbd578072 100644 --- a/react_on_rails_pro/yarn.lock +++ b/react_on_rails_pro/yarn.lock @@ -2377,6 +2377,11 @@ body-parser@^1.20.1, "body-parser@npm:empty-npm-package@1.0.0": resolved "https://registry.yarnpkg.com/empty-npm-package/-/empty-npm-package-1.0.0.tgz#fda29eb6de5efa391f73d578697853af55f6793a" integrity sha512-q4Mq/+XO7UNDdMiPpR/LIBIW1Zl4V0Z6UT9aKGqIAnBCtCb3lvZJM1KbDbdzdC8fKflwflModfjR29Nt0EpcwA== +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + boxen@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" @@ -2810,6 +2815,22 @@ crypto-random-string@^1.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" integrity sha512-GsVpkFPlycH7/fRR7Dhcmnoii54gV1nz7y4CWyeFS14N+JVBBhY+r8amRHE4BwSYal7BPTDp8isvAlCxyFt3Hg== +css-select@^5.1.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.2.2.tgz#01b6e8d163637bb2dd6c982ca4ed65863682786e" + integrity sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + +css-what@^6.1.0: + version "6.2.2" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea" + integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== + cssom@^0.4.4: version "0.4.4" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" @@ -2969,6 +2990,20 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + domexception@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" @@ -2976,6 +3011,22 @@ domexception@^2.0.1: dependencies: webidl-conversions "^5.0.0" +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.0.1: + version "3.2.2" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" + integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + dot-prop@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.1.tgz#45884194a71fc2cda71cbb4bceb3a4dd2f433ba4" @@ -3041,6 +3092,11 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" +entities@^4.2.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -4062,6 +4118,11 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + help-me@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/help-me/-/help-me-5.0.0.tgz#b1ebe63b967b74060027c2ac61f9be12d354a6f6" @@ -5450,6 +5511,14 @@ ndb@^1.1.5: optionalDependencies: node-pty "^0.9.0-beta18" +node-html-parser@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-7.0.1.tgz#e3056550bae48517ebf161a0b0638f4b0123dfe3" + integrity sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA== + dependencies: + css-select "^5.1.0" + he "1.2.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -5526,6 +5595,13 @@ nps@^5.9.12: type-detect "^4.0.3" yargs "14.2.0" +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + nwsapi@^2.2.0: version "2.2.10" resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.10.tgz#0b77a68e21a0b483db70b11fad055906e867cda8" diff --git a/yarn.lock b/yarn.lock index 6038b342d6..ee4c29eeb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1654,6 +1654,13 @@ resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/mock-fs@^4.13.4": + version "4.13.4" + resolved "https://registry.yarnpkg.com/@types/mock-fs/-/mock-fs-4.13.4.tgz#e73edb4b4889d44d23f1ea02d6eebe50aa30b09a" + integrity sha512-mXmM0o6lULPI8z3XNnQCpL0BGxPwx1Ul1wXYEPBGl4efShyxW2Rln0JOPEWGyZaYZMM6OVXM/15zUuFMY52ljg== + dependencies: + "@types/node" "*" + "@types/node@*", "@types/node@^20.17.16": version "20.17.16" resolved "https://registry.npmjs.org/@types/node/-/node-20.17.16.tgz" @@ -1662,9 +1669,9 @@ undici-types "~6.19.2" "@types/prop-types@*": - version "15.7.14" - resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz" - integrity sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ== + version "15.7.15" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" + integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== "@types/react-dom@^18.3.5": version "18.3.5" @@ -1672,9 +1679,9 @@ integrity sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q== "@types/react@^18.3.18": - version "18.3.18" - resolved "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz" - integrity sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ== + version "18.3.26" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.26.tgz#4c5970878d30db3d2a0bca1e4eb5f258e391bbeb" + integrity sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA== dependencies: "@types/prop-types" "*" csstype "^3.0.2" @@ -4729,6 +4736,11 @@ minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== +mock-fs@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/mock-fs/-/mock-fs-5.5.0.tgz#94a46d299aaa588e735a201cbe823c876e91f385" + integrity sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA== + mri@^1.1.0: version "1.2.0" resolved "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz"