diff --git a/fixtures/flight/server/global.js b/fixtures/flight/server/global.js index d81dc2c038c12..f097378056a46 100644 --- a/fixtures/flight/server/global.js +++ b/fixtures/flight/server/global.js @@ -104,6 +104,9 @@ async function renderApp(req, res, next) { if (req.headers['cache-control']) { proxiedHeaders['Cache-Control'] = req.get('cache-control'); } + if (req.get('rsc-request-id')) { + proxiedHeaders['rsc-request-id'] = req.get('rsc-request-id'); + } const requestsPrerender = req.path === '/prerender'; diff --git a/fixtures/flight/server/region.js b/fixtures/flight/server/region.js index a352d34ee6b79..3cc15c01cabb5 100644 --- a/fixtures/flight/server/region.js +++ b/fixtures/flight/server/region.js @@ -50,7 +50,27 @@ const {readFile} = require('fs').promises; const React = require('react'); -async function renderApp(res, returnValue, formState, noCache) { +const activeDebugChannels = + process.env.NODE_ENV === 'development' ? new Map() : null; + +function getDebugChannel(req) { + if (process.env.NODE_ENV !== 'development') { + return undefined; + } + const requestId = req.get('rsc-request-id'); + if (!requestId) { + return undefined; + } + return activeDebugChannels.get(requestId); +} + +async function renderApp( + res, + returnValue, + formState, + noCache, + promiseForDebugChannel +) { const {renderToPipeableStream} = await import( 'react-server-dom-webpack/server' ); @@ -101,7 +121,9 @@ async function renderApp(res, returnValue, formState, noCache) { ); // For client-invoked server actions we refresh the tree and return a return value. const payload = {root, returnValue, formState}; - const {pipe} = renderToPipeableStream(payload, moduleMap); + const {pipe} = renderToPipeableStream(payload, moduleMap, { + debugChannel: await promiseForDebugChannel, + }); pipe(res); } @@ -166,7 +188,7 @@ app.get('/', async function (req, res) { if ('prerender' in req.query) { await prerenderApp(res, null, null, noCache); } else { - await renderApp(res, null, null, noCache); + await renderApp(res, null, null, noCache, getDebugChannel(req)); } }); @@ -204,7 +226,7 @@ app.post('/', bodyParser.text(), async function (req, res) { // We handle the error on the client } // Refresh the client and return the value - renderApp(res, result, null, noCache); + renderApp(res, result, null, noCache, getDebugChannel(req)); } else { // This is the progressive enhancement case const UndiciRequest = require('undici').Request; @@ -220,11 +242,11 @@ app.post('/', bodyParser.text(), async function (req, res) { // Wait for any mutations const result = await action(); const formState = decodeFormState(result, formData); - renderApp(res, null, formState, noCache); + renderApp(res, null, formState, noCache, undefined); } catch (x) { const {setServerState} = await import('../src/ServerState.js'); setServerState('Error: ' + x.message); - renderApp(res, null, null, noCache); + renderApp(res, null, null, noCache, undefined); } } }); @@ -324,7 +346,7 @@ if (process.env.NODE_ENV === 'development') { }); } -app.listen(3001, () => { +const httpServer = app.listen(3001, () => { console.log('Regional Flight Server listening on port 3001...'); }); @@ -346,3 +368,27 @@ app.on('error', function (error) { throw error; } }); + +if (process.env.NODE_ENV === 'development') { + // Open a websocket server for Debug information + const WebSocket = require('ws'); + const webSocketServer = new WebSocket.Server({noServer: true}); + + httpServer.on('upgrade', (request, socket, head) => { + const DEBUG_CHANNEL_PATH = '/debug-channel?'; + if (request.url.startsWith(DEBUG_CHANNEL_PATH)) { + const requestId = request.url.slice(DEBUG_CHANNEL_PATH.length); + const promiseForWs = new Promise(resolve => { + webSocketServer.handleUpgrade(request, socket, head, ws => { + ws.on('close', () => { + activeDebugChannels.delete(requestId); + }); + resolve(ws); + }); + }); + activeDebugChannels.set(requestId, promiseForWs); + } else { + socket.destroy(); + } + }); +} diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index 5657b040ffa2b..b73162847d612 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -123,7 +123,6 @@ async function ServerComponent({noCache}) { export default async function App({prerender, noCache}) { const res = await fetch('http://localhost:3001/todos'); const todos = await res.json(); - console.log(res); const dedupedChild = ; const message = getServerState(); diff --git a/fixtures/flight/src/index.js b/fixtures/flight/src/index.js index f08f7a110bf61..3fd921a1bb5b4 100644 --- a/fixtures/flight/src/index.js +++ b/fixtures/flight/src/index.js @@ -42,17 +42,43 @@ function Shell({data}) { } async function hydrateApp() { - const {root, returnValue, formState} = await createFromFetch( - fetch('/', { - headers: { - Accept: 'text/x-component', - }, - }), - { - callServer, - findSourceMapURL, - } - ); + let response; + if ( + process.env.NODE_ENV === 'development' && + typeof WebSocketStream === 'function' + ) { + const requestId = crypto.randomUUID(); + const wss = new WebSocketStream( + 'ws://localhost:3001/debug-channel?' + requestId + ); + const debugChannel = await wss.opened; + response = createFromFetch( + fetch('/', { + headers: { + Accept: 'text/x-component', + 'rsc-request-id': requestId, + }, + }), + { + callServer, + debugChannel, + findSourceMapURL, + } + ); + } else { + response = createFromFetch( + fetch('/', { + headers: { + Accept: 'text/x-component', + }, + }), + { + callServer, + findSourceMapURL, + } + ); + } + const {root, returnValue, formState} = await response; ReactDOM.hydrateRoot( document, diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index cb19755734078..9d338c581f39d 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -328,6 +328,8 @@ export type FindSourceMapURLCallback = ( environmentName: string, ) => null | string; +export type DebugChannelCallback = (message: string) => void; + export type Response = { _bundlerConfig: ServerConsumerModuleMap, _serverReferenceConfig: null | ServerManifest, @@ -351,6 +353,7 @@ export type Response = { _debugRootStack?: null | Error, // DEV-only _debugRootTask?: null | ConsoleTask, // DEV-only _debugFindSourceMapURL?: void | FindSourceMapURLCallback, // DEV-only + _debugChannel?: void | DebugChannelCallback, // DEV-only _replayConsole: boolean, // DEV-only _rootEnvironmentName: string, // DEV-only, the requested environment name. }; @@ -687,6 +690,15 @@ export function reportGlobalError(response: Response, error: Error): void { triggerErrorOnChunk(chunk, error); } }); + if (__DEV__) { + const debugChannel = response._debugChannel; + if (debugChannel !== undefined) { + // If we don't have any more ways of reading data, we don't have to send any + // more neither. So we close the writable side. + debugChannel(''); + response._debugChannel = undefined; + } + } if (enableProfilerTimer && enableComponentPerformanceTrack) { markAllTracksInOrder(); flushComponentPerformance( @@ -1667,6 +1679,14 @@ function parseModelString( } case 'Y': { if (__DEV__) { + if (value.length > 2) { + const debugChannel = response._debugChannel; + if (debugChannel) { + const ref = value.slice(2); + debugChannel('R:' + ref); // Release this reference immediately + } + } + // In DEV mode we encode omitted objects in logs as a getter that throws // so that when you try to access it on the client, you know why that // happened. @@ -1730,9 +1750,10 @@ function ResponseInstance( encodeFormAction: void | EncodeFormActionCallback, nonce: void | string, temporaryReferences: void | TemporaryReferenceSet, - findSourceMapURL: void | FindSourceMapURLCallback, - replayConsole: boolean, - environmentName: void | string, + findSourceMapURL: void | FindSourceMapURLCallback, // DEV-only + replayConsole: boolean, // DEV-only + environmentName: void | string, // DEV-only + debugChannel: void | DebugChannelCallback, // DEV-only ) { const chunks: Map> = new Map(); this._bundlerConfig = bundlerConfig; @@ -1787,6 +1808,7 @@ function ResponseInstance( ); } this._debugFindSourceMapURL = findSourceMapURL; + this._debugChannel = debugChannel; this._replayConsole = replayConsole; this._rootEnvironmentName = rootEnv; } @@ -1802,9 +1824,10 @@ export function createResponse( encodeFormAction: void | EncodeFormActionCallback, nonce: void | string, temporaryReferences: void | TemporaryReferenceSet, - findSourceMapURL: void | FindSourceMapURLCallback, - replayConsole: boolean, - environmentName: void | string, + findSourceMapURL: void | FindSourceMapURLCallback, // DEV-only + replayConsole: boolean, // DEV-only + environmentName: void | string, // DEV-only + debugChannel: void | DebugChannelCallback, // DEV-only ): Response { // $FlowFixMe[invalid-constructor]: the shapes are exact here but Flow doesn't like constructors return new ResponseInstance( @@ -1818,6 +1841,7 @@ export function createResponse( findSourceMapURL, replayConsole, environmentName, + debugChannel, ); } diff --git a/packages/react-client/src/__tests__/ReactFlightDebugChannel-test.js b/packages/react-client/src/__tests__/ReactFlightDebugChannel-test.js new file mode 100644 index 0000000000000..e9428c3ba4074 --- /dev/null +++ b/packages/react-client/src/__tests__/ReactFlightDebugChannel-test.js @@ -0,0 +1,139 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment node + */ + +'use strict'; + +if (typeof Blob === 'undefined') { + global.Blob = require('buffer').Blob; +} +if (typeof File === 'undefined' || typeof FormData === 'undefined') { + global.File = require('undici').File; + global.FormData = require('undici').FormData; +} + +function formatV8Stack(stack) { + let v8StyleStack = ''; + if (stack) { + for (let i = 0; i < stack.length; i++) { + const [name] = stack[i]; + if (v8StyleStack !== '') { + v8StyleStack += '\n'; + } + v8StyleStack += ' in ' + name + ' (at **)'; + } + } + return v8StyleStack; +} + +function normalizeComponentInfo(debugInfo) { + if (Array.isArray(debugInfo.stack)) { + const {debugTask, debugStack, ...copy} = debugInfo; + copy.stack = formatV8Stack(debugInfo.stack); + if (debugInfo.owner) { + copy.owner = normalizeComponentInfo(debugInfo.owner); + } + return copy; + } else { + return debugInfo; + } +} + +function getDebugInfo(obj) { + const debugInfo = obj._debugInfo; + if (debugInfo) { + const copy = []; + for (let i = 0; i < debugInfo.length; i++) { + copy.push(normalizeComponentInfo(debugInfo[i])); + } + return copy; + } + return debugInfo; +} + +let act; +let React; +let ReactNoop; +let ReactNoopFlightServer; +let ReactNoopFlightClient; + +describe('ReactFlight', () => { + beforeEach(() => { + // Mock performance.now for timing tests + let time = 10; + const now = jest.fn().mockImplementation(() => { + return time++; + }); + Object.defineProperty(performance, 'timeOrigin', { + value: time, + configurable: true, + }); + Object.defineProperty(performance, 'now', { + value: now, + configurable: true, + }); + + jest.resetModules(); + jest.mock('react', () => require('react/react.react-server')); + ReactNoopFlightServer = require('react-noop-renderer/flight-server'); + // This stores the state so we need to preserve it + const flightModules = require('react-noop-renderer/flight-modules'); + jest.resetModules(); + __unmockReact(); + jest.mock('react-noop-renderer/flight-modules', () => flightModules); + React = require('react'); + ReactNoop = require('react-noop-renderer'); + ReactNoopFlightClient = require('react-noop-renderer/flight-client'); + act = require('internal-test-utils').act; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // @gate __DEV__ && enableComponentPerformanceTrack + it('can render deep but cut off JSX in debug info', async () => { + function createDeepJSX(n) { + if (n <= 0) { + return null; + } + return
{createDeepJSX(n - 1)}
; + } + + function ServerComponent(props) { + return
not using props
; + } + + const debugChannel = {onMessage(message) {}}; + + const transport = ReactNoopFlightServer.render( + { + root: ( + + {createDeepJSX(100) /* deper than objectLimit */} + + ), + }, + {debugChannel}, + ); + + await act(async () => { + const rootModel = await ReactNoopFlightClient.read(transport, { + debugChannel, + }); + const root = rootModel.root; + const children = getDebugInfo(root)[1].props.children; + expect(children.type).toBe('div'); + expect(children.props.children.type).toBe('div'); + ReactNoop.render(root); + }); + + expect(ReactNoop).toMatchRenderedOutput(
not using props
); + }); +}); diff --git a/packages/react-markup/src/ReactMarkupServer.js b/packages/react-markup/src/ReactMarkupServer.js index d3950c568f7ce..f144211711d75 100644 --- a/packages/react-markup/src/ReactMarkupServer.js +++ b/packages/react-markup/src/ReactMarkupServer.js @@ -171,6 +171,7 @@ export function experimental_renderToHTML( undefined, 'Markup', undefined, + false, ); const flightResponse = createFlightResponse( null, diff --git a/packages/react-noop-renderer/src/ReactNoopFlightClient.js b/packages/react-noop-renderer/src/ReactNoopFlightClient.js index c2be176b1a8b1..d4e6e6ea7936e 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightClient.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightClient.js @@ -56,6 +56,7 @@ const {createResponse, processBinaryChunk, getRoot, close} = ReactFlightClient({ type ReadOptions = {| findSourceMapURL?: FindSourceMapURLCallback, + debugChannel?: {onMessage: (message: string) => void}, close?: boolean, |}; @@ -71,6 +72,9 @@ function read(source: Source, options: ReadOptions): Thenable { options !== undefined ? options.findSourceMapURL : undefined, true, undefined, + __DEV__ && options !== undefined && options.debugChannel !== undefined + ? options.debugChannel.onMessage + : undefined, ); for (let i = 0; i < source.length; i++) { processBinaryChunk(response, source[i], 0); diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index 7c3790e9cf052..d54ebe30c89da 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -71,6 +71,7 @@ type Options = { filterStackFrame?: (url: string, functionName: string) => boolean, identifierPrefix?: string, signal?: AbortSignal, + debugChannel?: {onMessage?: (message: string) => void}, onError?: (error: mixed) => void, onPostpone?: (reason: string) => void, }; @@ -87,6 +88,7 @@ function render(model: ReactClientValue, options?: Options): Destination { undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + __DEV__ && options && options.debugChannel !== undefined, ); const signal = options ? options.signal : undefined; if (signal) { @@ -100,6 +102,11 @@ function render(model: ReactClientValue, options?: Options): Destination { signal.addEventListener('abort', listener); } } + if (__DEV__ && options && options.debugChannel !== undefined) { + options.debugChannel.onMessage = message => { + ReactNoopFlightServer.resolveDebugMessage(request, message); + }; + } ReactNoopFlightServer.startWork(request); ReactNoopFlightServer.startFlowing(request, destination); return destination; diff --git a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js index 9ae47e3b5512d..9e4a9efb58cca 100644 --- a/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-esm/src/client/ReactFlightDOMClientBrowser.js @@ -12,6 +12,7 @@ import type {Thenable} from 'shared/ReactTypes.js'; import type { Response as FlightResponse, FindSourceMapURLCallback, + DebugChannelCallback, } from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -43,12 +44,31 @@ type CallServerCallback = (string, args: A) => Promise; export type Options = { moduleBaseURL?: string, callServer?: CallServerCallback, + debugChannel?: {writable?: WritableStream, ...}, temporaryReferences?: TemporaryReferenceSet, findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, }; +function createDebugCallbackFromWritableStream( + debugWritable: WritableStream, +): DebugChannelCallback { + const textEncoder = new TextEncoder(); + const writer = debugWritable.getWriter(); + return message => { + if (message === '') { + writer.close(); + } else { + // Note: It's important that this function doesn't close over the Response object or it can't be GC:ed. + // Therefore, we can't report errors from this write back to the Response object. + if (__DEV__) { + writer.write(textEncoder.encode(message + '\n')).catch(console.error); + } + } + }; +} + function createResponseFromOptions(options: void | Options) { return createResponse( options && options.moduleBaseURL ? options.moduleBaseURL : '', @@ -67,6 +87,12 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && + options && + options.debugChannel !== undefined && + options.debugChannel.writable !== undefined + ? createDebugCallbackFromWritableStream(options.debugChannel.writable) + : undefined, ); } diff --git a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js index a9c978b493012..6f530dd2b6fd7 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js @@ -18,6 +18,8 @@ import type {Busboy} from 'busboy'; import type {Writable} from 'stream'; import type {Thenable} from 'shared/ReactTypes'; +import type {Duplex} from 'stream'; + import {Readable} from 'stream'; import { @@ -27,6 +29,8 @@ import { startFlowing, stopFlowing, abort, + resolveDebugMessage, + closeDebugChannel, } from 'react-server/src/ReactFlightServer'; import { @@ -50,6 +54,12 @@ export { registerClientReference, } from '../ReactFlightESMReferences'; +import { + createStringDecoder, + readPartialStringChunk, + readFinalStringChunk, +} from 'react-client/src/ReactFlightClientStreamConfigNode'; + import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -67,7 +77,69 @@ function createCancelHandler(request: Request, reason: string) { }; } +function startReadingFromDebugChannelReadable( + request: Request, + stream: Readable | WebSocket, +): void { + const stringDecoder = createStringDecoder(); + let lastWasPartial = false; + let stringBuffer = ''; + function onData(chunk: string | Uint8Array) { + if (typeof chunk === 'string') { + if (lastWasPartial) { + stringBuffer += readFinalStringChunk(stringDecoder, new Uint8Array(0)); + lastWasPartial = false; + } + stringBuffer += chunk; + } else { + const buffer: Uint8Array = (chunk: any); + stringBuffer += readPartialStringChunk(stringDecoder, buffer); + lastWasPartial = true; + } + const messages = stringBuffer.split('\n'); + for (let i = 0; i < messages.length - 1; i++) { + resolveDebugMessage(request, messages[i]); + } + stringBuffer = messages[messages.length - 1]; + } + function onError(error: mixed) { + abort( + request, + new Error('Lost connection to the Debug Channel.', { + cause: error, + }), + ); + } + function onClose() { + closeDebugChannel(request); + } + if ( + // $FlowFixMe[method-unbinding] + typeof stream.addEventListener === 'function' && + // $FlowFixMe[method-unbinding] + typeof stream.binaryType === 'string' + ) { + const ws: WebSocket = (stream: any); + ws.binaryType = 'arraybuffer'; + ws.addEventListener('message', event => { + // $FlowFixMe + onData(event.data); + }); + ws.addEventListener('error', event => { + // $FlowFixMe + onError(event.error); + }); + ws.addEventListener('close', onClose); + } else { + const readable: Readable = (stream: any); + readable.on('data', onData); + readable.on('error', onError); + readable.on('end', onClose); + } +} + type Options = { + debugChannel?: Readable | Duplex | WebSocket, environmentName?: string | (() => string), filterStackFrame?: (url: string, functionName: string) => boolean, onError?: (error: mixed) => void, @@ -86,6 +158,7 @@ function renderToPipeableStream( moduleBasePath: ClientManifest, options?: Options, ): PipeableStream { + const debugChannel = __DEV__ && options ? options.debugChannel : undefined; const request = createRequest( model, moduleBasePath, @@ -95,9 +168,13 @@ function renderToPipeableStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + debugChannel !== undefined, ); let hasStartedFlowing = false; startWork(request); + if (debugChannel !== undefined) { + startReadingFromDebugChannelReadable(request, debugChannel); + } return { pipe(destination: T): T { if (hasStartedFlowing) { @@ -126,11 +203,12 @@ function renderToPipeableStream( }, }; } + function createFakeWritable(readable: any): Writable { // The current host config expects a Writable so we create // a fake writable for now to push into the Readable. return ({ - write(chunk) { + write(chunk: string | Uint8Array) { return readable.push(chunk); }, end() { @@ -184,6 +262,7 @@ function prerenderToNodeStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + false, ); if (options && options.signal) { const signal = options.signal; @@ -287,8 +366,8 @@ function decodeReply( export { renderToPipeableStream, prerenderToNodeStream, - decodeReplyFromBusboy, decodeReply, + decodeReplyFromBusboy, decodeAction, decodeFormState, }; diff --git a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js index 3aca4a355dce7..47098d94902de 100644 --- a/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-parcel/src/client/ReactFlightDOMClientBrowser.js @@ -8,7 +8,10 @@ */ import type {Thenable} from 'shared/ReactTypes.js'; -import type {Response as FlightResponse} from 'react-client/src/ReactFlightClient'; +import type { + Response as FlightResponse, + DebugChannelCallback, +} from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; import type {ServerReferenceId} from '../client/ReactFlightClientConfigBundlerParcel'; @@ -76,6 +79,24 @@ export function createServerReference, T>( ); } +function createDebugCallbackFromWritableStream( + debugWritable: WritableStream, +): DebugChannelCallback { + const textEncoder = new TextEncoder(); + const writer = debugWritable.getWriter(); + return message => { + if (message === '') { + writer.close(); + } else { + // Note: It's important that this function doesn't close over the Response object or it can't be GC:ed. + // Therefore, we can't report errors from this write back to the Response object. + if (__DEV__) { + writer.write(textEncoder.encode(message + '\n')).catch(console.error); + } + } + }; +} + function startReadingFromStream( response: FlightResponse, stream: ReadableStream, @@ -104,6 +125,7 @@ function startReadingFromStream( } export type Options = { + debugChannel?: {writable?: WritableStream, ...}, temporaryReferences?: TemporaryReferenceSet, replayConsoleLogs?: boolean, environmentName?: string, @@ -128,6 +150,12 @@ export function createFromReadableStream( __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && + options && + options.debugChannel !== undefined && + options.debugChannel.writable !== undefined + ? createDebugCallbackFromWritableStream(options.debugChannel.writable) + : undefined, ); startReadingFromStream(response, stream); return getRoot(response); @@ -152,6 +180,12 @@ export function createFromFetch( __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && + options && + options.debugChannel !== undefined && + options.debugChannel.writable !== undefined + ? createDebugCallbackFromWritableStream(options.debugChannel.writable) + : undefined, ); promiseForResponse.then( function (r) { diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js index 73a8741618213..988f5628a919a 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerBrowser.js @@ -7,7 +7,10 @@ * @flow */ -import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; +import type { + Request, + ReactClientValue, +} from 'react-server/src/ReactFlightServer'; import type {ReactFormState, Thenable} from 'shared/ReactTypes'; import { preloadModule, @@ -24,6 +27,8 @@ import { startFlowing, stopFlowing, abort, + resolveDebugMessage, + closeDebugChannel, } from 'react-server/src/ReactFlightServer'; import { @@ -42,12 +47,19 @@ export { registerServerReference, } from '../ReactFlightParcelReferences'; +import { + createStringDecoder, + readPartialStringChunk, + readFinalStringChunk, +} from 'react-client/src/ReactFlightClientStreamConfigWeb'; + import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; export type {TemporaryReferenceSet}; type Options = { + debugChannel?: {readable?: ReadableStream, ...}, environmentName?: string | (() => string), filterStackFrame?: (url: string, functionName: string) => boolean, identifierPrefix?: string, @@ -57,10 +69,55 @@ type Options = { onPostpone?: (reason: string) => void, }; +function startReadingFromDebugChannelReadableStream( + request: Request, + stream: ReadableStream, +): void { + const reader = stream.getReader(); + const stringDecoder = createStringDecoder(); + let stringBuffer = ''; + function progress({ + done, + value, + }: { + done: boolean, + value: ?any, + ... + }): void | Promise { + const buffer: Uint8Array = (value: any); + stringBuffer += done + ? readFinalStringChunk(stringDecoder, new Uint8Array(0)) + : readPartialStringChunk(stringDecoder, buffer); + const messages = stringBuffer.split('\n'); + for (let i = 0; i < messages.length - 1; i++) { + resolveDebugMessage(request, messages[i]); + } + stringBuffer = messages[messages.length - 1]; + if (done) { + closeDebugChannel(request); + return; + } + return reader.read().then(progress).catch(error); + } + function error(e: any) { + abort( + request, + new Error('Lost connection to the Debug Channel.', { + cause: e, + }), + ); + } + reader.read().then(progress).catch(error); +} + export function renderToReadableStream( model: ReactClientValue, options?: Options, ): ReadableStream { + const debugChannelReadable = + __DEV__ && options && options.debugChannel + ? options.debugChannel.readable + : undefined; const request = createRequest( model, null, @@ -70,6 +127,7 @@ export function renderToReadableStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + debugChannelReadable !== undefined, ); if (options && options.signal) { const signal = options.signal; @@ -83,6 +141,9 @@ export function renderToReadableStream( signal.addEventListener('abort', listener); } } + if (debugChannelReadable !== undefined) { + startReadingFromDebugChannelReadableStream(request, debugChannelReadable); + } const stream = new ReadableStream( { type: 'bytes', @@ -117,9 +178,6 @@ export function prerender( const stream = new ReadableStream( { type: 'bytes', - start: (controller): ?Promise => { - startWork(request); - }, pull: (controller): ?Promise => { startFlowing(request, controller); }, @@ -144,6 +202,7 @@ export function prerender( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + false, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js index 2a365993a7cfb..54d9a78c5f92a 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerEdge.js @@ -7,7 +7,10 @@ * @flow */ -import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; +import type { + Request, + ReactClientValue, +} from 'react-server/src/ReactFlightServer'; import type {ReactFormState, Thenable} from 'shared/ReactTypes'; import { preloadModule, @@ -26,6 +29,8 @@ import { startFlowing, stopFlowing, abort, + resolveDebugMessage, + closeDebugChannel, } from 'react-server/src/ReactFlightServer'; import { @@ -47,12 +52,19 @@ export { registerServerReference, } from '../ReactFlightParcelReferences'; +import { + createStringDecoder, + readPartialStringChunk, + readFinalStringChunk, +} from 'react-client/src/ReactFlightClientStreamConfigWeb'; + import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; export type {TemporaryReferenceSet}; type Options = { + debugChannel?: {readable?: ReadableStream, ...}, environmentName?: string | (() => string), filterStackFrame?: (url: string, functionName: string) => boolean, identifierPrefix?: string, @@ -62,10 +74,55 @@ type Options = { onPostpone?: (reason: string) => void, }; +function startReadingFromDebugChannelReadableStream( + request: Request, + stream: ReadableStream, +): void { + const reader = stream.getReader(); + const stringDecoder = createStringDecoder(); + let stringBuffer = ''; + function progress({ + done, + value, + }: { + done: boolean, + value: ?any, + ... + }): void | Promise { + const buffer: Uint8Array = (value: any); + stringBuffer += done + ? readFinalStringChunk(stringDecoder, new Uint8Array(0)) + : readPartialStringChunk(stringDecoder, buffer); + const messages = stringBuffer.split('\n'); + for (let i = 0; i < messages.length - 1; i++) { + resolveDebugMessage(request, messages[i]); + } + stringBuffer = messages[messages.length - 1]; + if (done) { + closeDebugChannel(request); + return; + } + return reader.read().then(progress).catch(error); + } + function error(e: any) { + abort( + request, + new Error('Lost connection to the Debug Channel.', { + cause: e, + }), + ); + } + reader.read().then(progress).catch(error); +} + export function renderToReadableStream( model: ReactClientValue, options?: Options, ): ReadableStream { + const debugChannelReadable = + __DEV__ && options && options.debugChannel + ? options.debugChannel.readable + : undefined; const request = createRequest( model, null, @@ -75,6 +132,7 @@ export function renderToReadableStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + debugChannelReadable !== undefined, ); if (options && options.signal) { const signal = options.signal; @@ -88,6 +146,9 @@ export function renderToReadableStream( signal.addEventListener('abort', listener); } } + if (debugChannelReadable !== undefined) { + startReadingFromDebugChannelReadableStream(request, debugChannelReadable); + } const stream = new ReadableStream( { type: 'bytes', @@ -122,9 +183,6 @@ export function prerender( const stream = new ReadableStream( { type: 'bytes', - start: (controller): ?Promise => { - startWork(request); - }, pull: (controller): ?Promise => { startFlowing(request, controller); }, @@ -149,6 +207,7 @@ export function prerender( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + false, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js index 9ce1d43fa718d..abdd6452793dd 100644 --- a/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js @@ -20,6 +20,8 @@ import type { ServerReferenceId, } from '../client/ReactFlightClientConfigBundlerParcel'; +import type {Duplex} from 'stream'; + import {Readable} from 'stream'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; @@ -31,6 +33,8 @@ import { startFlowing, stopFlowing, abort, + resolveDebugMessage, + closeDebugChannel, } from 'react-server/src/ReactFlightServer'; import { @@ -49,6 +53,7 @@ import { decodeAction as decodeActionImpl, decodeFormState as decodeFormStateImpl, } from 'react-server/src/ReactFlightActionServer'; + import { preloadModule, requireModule, @@ -60,6 +65,12 @@ export { registerServerReference, } from '../ReactFlightParcelReferences'; +import { + createStringDecoder, + readPartialStringChunk, + readFinalStringChunk, +} from 'react-client/src/ReactFlightClientStreamConfigNode'; + import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -79,7 +90,69 @@ function createCancelHandler(request: Request, reason: string) { }; } +function startReadingFromDebugChannelReadable( + request: Request, + stream: Readable | WebSocket, +): void { + const stringDecoder = createStringDecoder(); + let lastWasPartial = false; + let stringBuffer = ''; + function onData(chunk: string | Uint8Array) { + if (typeof chunk === 'string') { + if (lastWasPartial) { + stringBuffer += readFinalStringChunk(stringDecoder, new Uint8Array(0)); + lastWasPartial = false; + } + stringBuffer += chunk; + } else { + const buffer: Uint8Array = (chunk: any); + stringBuffer += readPartialStringChunk(stringDecoder, buffer); + lastWasPartial = true; + } + const messages = stringBuffer.split('\n'); + for (let i = 0; i < messages.length - 1; i++) { + resolveDebugMessage(request, messages[i]); + } + stringBuffer = messages[messages.length - 1]; + } + function onError(error: mixed) { + abort( + request, + new Error('Lost connection to the Debug Channel.', { + cause: error, + }), + ); + } + function onClose() { + closeDebugChannel(request); + } + if ( + // $FlowFixMe[method-unbinding] + typeof stream.addEventListener === 'function' && + // $FlowFixMe[method-unbinding] + typeof stream.binaryType === 'string' + ) { + const ws: WebSocket = (stream: any); + ws.binaryType = 'arraybuffer'; + ws.addEventListener('message', event => { + // $FlowFixMe + onData(event.data); + }); + ws.addEventListener('error', event => { + // $FlowFixMe + onError(event.error); + }); + ws.addEventListener('close', onClose); + } else { + const readable: Readable = (stream: any); + readable.on('data', onData); + readable.on('error', onError); + readable.on('end', onClose); + } +} + type Options = { + debugChannel?: Readable | Duplex | WebSocket, environmentName?: string | (() => string), filterStackFrame?: (url: string, functionName: string) => boolean, onError?: (error: mixed) => void, @@ -97,6 +170,7 @@ export function renderToPipeableStream( model: ReactClientValue, options?: Options, ): PipeableStream { + const debugChannel = __DEV__ && options ? options.debugChannel : undefined; const request = createRequest( model, null, @@ -106,9 +180,13 @@ export function renderToPipeableStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + debugChannel !== undefined, ); let hasStartedFlowing = false; startWork(request); + if (debugChannel !== undefined) { + startReadingFromDebugChannelReadable(request, debugChannel); + } return { pipe(destination: T): T { if (hasStartedFlowing) { @@ -149,7 +227,7 @@ function createFakeWritableFromReadableStreamController( chunk = textEncoder.encode(chunk); } controller.enqueue(chunk); - // in web streams there is no backpressure so we can alwas write more + // in web streams there is no backpressure so we can always write more return true; }, end() { @@ -167,13 +245,58 @@ function createFakeWritableFromReadableStreamController( }: any); } +function startReadingFromDebugChannelReadableStream( + request: Request, + stream: ReadableStream, +): void { + const reader = stream.getReader(); + const stringDecoder = createStringDecoder(); + let stringBuffer = ''; + function progress({ + done, + value, + }: { + done: boolean, + value: ?any, + ... + }): void | Promise { + const buffer: Uint8Array = (value: any); + stringBuffer += done + ? readFinalStringChunk(stringDecoder, new Uint8Array(0)) + : readPartialStringChunk(stringDecoder, buffer); + const messages = stringBuffer.split('\n'); + for (let i = 0; i < messages.length - 1; i++) { + resolveDebugMessage(request, messages[i]); + } + stringBuffer = messages[messages.length - 1]; + if (done) { + closeDebugChannel(request); + return; + } + return reader.read().then(progress).catch(error); + } + function error(e: any) { + abort( + request, + new Error('Lost connection to the Debug Channel.', { + cause: e, + }), + ); + } + reader.read().then(progress).catch(error); +} + export function renderToReadableStream( model: ReactClientValue, - - options?: Options & { + options?: Omit & { + debugChannel?: {readable?: ReadableStream, ...}, signal?: AbortSignal, }, ): ReadableStream { + const debugChannelReadable = + __DEV__ && options && options.debugChannel + ? options.debugChannel.readable + : undefined; const request = createRequest( model, null, @@ -183,6 +306,7 @@ export function renderToReadableStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + debugChannelReadable !== undefined, ); if (options && options.signal) { const signal = options.signal; @@ -196,6 +320,9 @@ export function renderToReadableStream( signal.addEventListener('abort', listener); } } + if (debugChannelReadable !== undefined) { + startReadingFromDebugChannelReadableStream(request, debugChannelReadable); + } let writable: Writable; const stream = new ReadableStream( { @@ -275,6 +402,7 @@ export function prerenderToNodeStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + false, ); if (options && options.signal) { const signal = options.signal; @@ -296,7 +424,6 @@ export function prerenderToNodeStream( export function prerender( model: ReactClientValue, - options?: Options & { signal?: AbortSignal, }, @@ -338,6 +465,7 @@ export function prerender( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + false, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js index ee319beca18ef..0a3b6cedc8224 100644 --- a/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-turbopack/src/client/ReactFlightDOMClientBrowser.js @@ -12,6 +12,7 @@ import type {Thenable} from 'shared/ReactTypes.js'; import type { Response as FlightResponse, FindSourceMapURLCallback, + DebugChannelCallback, } from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -42,12 +43,31 @@ type CallServerCallback = (string, args: A) => Promise; export type Options = { callServer?: CallServerCallback, + debugChannel?: {writable?: WritableStream, ...}, temporaryReferences?: TemporaryReferenceSet, findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, }; +function createDebugCallbackFromWritableStream( + debugWritable: WritableStream, +): DebugChannelCallback { + const textEncoder = new TextEncoder(); + const writer = debugWritable.getWriter(); + return message => { + if (message === '') { + writer.close(); + } else { + // Note: It's important that this function doesn't close over the Response object or it can't be GC:ed. + // Therefore, we can't report errors from this write back to the Response object. + if (__DEV__) { + writer.write(textEncoder.encode(message + '\n')).catch(console.error); + } + } + }; +} + function createResponseFromOptions(options: void | Options) { return createResponse( null, @@ -66,6 +86,12 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && + options && + options.debugChannel !== undefined && + options.debugChannel.writable !== undefined + ? createDebugCallbackFromWritableStream(options.debugChannel.writable) + : undefined, ); } diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js index 11dbe1a7c1358..0f09f82b90d74 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerBrowser.js @@ -7,7 +7,10 @@ * @flow */ -import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; +import type { + Request, + ReactClientValue, +} from 'react-server/src/ReactFlightServer'; import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; @@ -19,6 +22,8 @@ import { startFlowing, stopFlowing, abort, + resolveDebugMessage, + closeDebugChannel, } from 'react-server/src/ReactFlightServer'; import { @@ -38,6 +43,12 @@ export { createClientModuleProxy, } from '../ReactFlightTurbopackReferences'; +import { + createStringDecoder, + readPartialStringChunk, + readFinalStringChunk, +} from 'react-client/src/ReactFlightClientStreamConfigWeb'; + import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -45,6 +56,7 @@ export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTem export type {TemporaryReferenceSet}; type Options = { + debugChannel?: {readable?: ReadableStream, ...}, environmentName?: string | (() => string), filterStackFrame?: (url: string, functionName: string) => boolean, identifierPrefix?: string, @@ -54,11 +66,56 @@ type Options = { onPostpone?: (reason: string) => void, }; +function startReadingFromDebugChannelReadableStream( + request: Request, + stream: ReadableStream, +): void { + const reader = stream.getReader(); + const stringDecoder = createStringDecoder(); + let stringBuffer = ''; + function progress({ + done, + value, + }: { + done: boolean, + value: ?any, + ... + }): void | Promise { + const buffer: Uint8Array = (value: any); + stringBuffer += done + ? readFinalStringChunk(stringDecoder, new Uint8Array(0)) + : readPartialStringChunk(stringDecoder, buffer); + const messages = stringBuffer.split('\n'); + for (let i = 0; i < messages.length - 1; i++) { + resolveDebugMessage(request, messages[i]); + } + stringBuffer = messages[messages.length - 1]; + if (done) { + closeDebugChannel(request); + return; + } + return reader.read().then(progress).catch(error); + } + function error(e: any) { + abort( + request, + new Error('Lost connection to the Debug Channel.', { + cause: e, + }), + ); + } + reader.read().then(progress).catch(error); +} + function renderToReadableStream( model: ReactClientValue, turbopackMap: ClientManifest, options?: Options, ): ReadableStream { + const debugChannelReadable = + __DEV__ && options && options.debugChannel + ? options.debugChannel.readable + : undefined; const request = createRequest( model, turbopackMap, @@ -68,6 +125,7 @@ function renderToReadableStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + debugChannelReadable !== undefined, ); if (options && options.signal) { const signal = options.signal; @@ -81,6 +139,9 @@ function renderToReadableStream( signal.addEventListener('abort', listener); } } + if (debugChannelReadable !== undefined) { + startReadingFromDebugChannelReadableStream(request, debugChannelReadable); + } const stream = new ReadableStream( { type: 'bytes', @@ -116,9 +177,6 @@ function prerender( const stream = new ReadableStream( { type: 'bytes', - start: (controller): ?Promise => { - startWork(request); - }, pull: (controller): ?Promise => { startFlowing(request, controller); }, @@ -143,6 +201,7 @@ function prerender( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + false, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js index e8256767fa5b1..07ed44059f070 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerEdge.js @@ -7,7 +7,10 @@ * @flow */ -import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; +import type { + Request, + ReactClientValue, +} from 'react-server/src/ReactFlightServer'; import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigTurbopackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; @@ -21,6 +24,8 @@ import { startFlowing, stopFlowing, abort, + resolveDebugMessage, + closeDebugChannel, } from 'react-server/src/ReactFlightServer'; import { @@ -43,6 +48,12 @@ export { createClientModuleProxy, } from '../ReactFlightTurbopackReferences'; +import { + createStringDecoder, + readPartialStringChunk, + readFinalStringChunk, +} from 'react-client/src/ReactFlightClientStreamConfigWeb'; + import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -50,6 +61,7 @@ export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTem export type {TemporaryReferenceSet}; type Options = { + debugChannel?: {readable?: ReadableStream, ...}, environmentName?: string | (() => string), filterStackFrame?: (url: string, functionName: string) => boolean, identifierPrefix?: string, @@ -59,11 +71,56 @@ type Options = { onPostpone?: (reason: string) => void, }; +function startReadingFromDebugChannelReadableStream( + request: Request, + stream: ReadableStream, +): void { + const reader = stream.getReader(); + const stringDecoder = createStringDecoder(); + let stringBuffer = ''; + function progress({ + done, + value, + }: { + done: boolean, + value: ?any, + ... + }): void | Promise { + const buffer: Uint8Array = (value: any); + stringBuffer += done + ? readFinalStringChunk(stringDecoder, new Uint8Array(0)) + : readPartialStringChunk(stringDecoder, buffer); + const messages = stringBuffer.split('\n'); + for (let i = 0; i < messages.length - 1; i++) { + resolveDebugMessage(request, messages[i]); + } + stringBuffer = messages[messages.length - 1]; + if (done) { + closeDebugChannel(request); + return; + } + return reader.read().then(progress).catch(error); + } + function error(e: any) { + abort( + request, + new Error('Lost connection to the Debug Channel.', { + cause: e, + }), + ); + } + reader.read().then(progress).catch(error); +} + function renderToReadableStream( model: ReactClientValue, turbopackMap: ClientManifest, options?: Options, ): ReadableStream { + const debugChannelReadable = + __DEV__ && options && options.debugChannel + ? options.debugChannel.readable + : undefined; const request = createRequest( model, turbopackMap, @@ -73,6 +130,7 @@ function renderToReadableStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + debugChannelReadable !== undefined, ); if (options && options.signal) { const signal = options.signal; @@ -86,6 +144,9 @@ function renderToReadableStream( signal.addEventListener('abort', listener); } } + if (debugChannelReadable !== undefined) { + startReadingFromDebugChannelReadableStream(request, debugChannelReadable); + } const stream = new ReadableStream( { type: 'bytes', @@ -121,9 +182,6 @@ function prerender( const stream = new ReadableStream( { type: 'bytes', - start: (controller): ?Promise => { - startWork(request); - }, pull: (controller): ?Promise => { startFlowing(request, controller); }, @@ -148,6 +206,7 @@ function prerender( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + false, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js index 10d39e67a8169..8e18f71909856 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightDOMServerNode.js @@ -18,6 +18,8 @@ import type {Busboy} from 'busboy'; import type {Writable} from 'stream'; import type {Thenable} from 'shared/ReactTypes'; +import type {Duplex} from 'stream'; + import {Readable} from 'stream'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; @@ -29,6 +31,8 @@ import { startFlowing, stopFlowing, abort, + resolveDebugMessage, + closeDebugChannel, } from 'react-server/src/ReactFlightServer'; import { @@ -54,6 +58,12 @@ export { createClientModuleProxy, } from '../ReactFlightTurbopackReferences'; +import { + createStringDecoder, + readPartialStringChunk, + readFinalStringChunk, +} from 'react-client/src/ReactFlightClientStreamConfigNode'; + import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -73,7 +83,69 @@ function createCancelHandler(request: Request, reason: string) { }; } +function startReadingFromDebugChannelReadable( + request: Request, + stream: Readable | WebSocket, +): void { + const stringDecoder = createStringDecoder(); + let lastWasPartial = false; + let stringBuffer = ''; + function onData(chunk: string | Uint8Array) { + if (typeof chunk === 'string') { + if (lastWasPartial) { + stringBuffer += readFinalStringChunk(stringDecoder, new Uint8Array(0)); + lastWasPartial = false; + } + stringBuffer += chunk; + } else { + const buffer: Uint8Array = (chunk: any); + stringBuffer += readPartialStringChunk(stringDecoder, buffer); + lastWasPartial = true; + } + const messages = stringBuffer.split('\n'); + for (let i = 0; i < messages.length - 1; i++) { + resolveDebugMessage(request, messages[i]); + } + stringBuffer = messages[messages.length - 1]; + } + function onError(error: mixed) { + abort( + request, + new Error('Lost connection to the Debug Channel.', { + cause: error, + }), + ); + } + function onClose() { + closeDebugChannel(request); + } + if ( + // $FlowFixMe[method-unbinding] + typeof stream.addEventListener === 'function' && + // $FlowFixMe[method-unbinding] + typeof stream.binaryType === 'string' + ) { + const ws: WebSocket = (stream: any); + ws.binaryType = 'arraybuffer'; + ws.addEventListener('message', event => { + // $FlowFixMe + onData(event.data); + }); + ws.addEventListener('error', event => { + // $FlowFixMe + onError(event.error); + }); + ws.addEventListener('close', onClose); + } else { + const readable: Readable = (stream: any); + readable.on('data', onData); + readable.on('error', onError); + readable.on('end', onClose); + } +} + type Options = { + debugChannel?: Readable | Duplex | WebSocket, environmentName?: string | (() => string), filterStackFrame?: (url: string, functionName: string) => boolean, onError?: (error: mixed) => void, @@ -92,6 +164,7 @@ function renderToPipeableStream( turbopackMap: ClientManifest, options?: Options, ): PipeableStream { + const debugChannel = __DEV__ && options ? options.debugChannel : undefined; const request = createRequest( model, turbopackMap, @@ -101,9 +174,13 @@ function renderToPipeableStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + debugChannel !== undefined, ); let hasStartedFlowing = false; startWork(request); + if (debugChannel !== undefined) { + startReadingFromDebugChannelReadable(request, debugChannel); + } return { pipe(destination: T): T { if (hasStartedFlowing) { @@ -162,13 +239,59 @@ function createFakeWritableFromReadableStreamController( }: any); } +function startReadingFromDebugChannelReadableStream( + request: Request, + stream: ReadableStream, +): void { + const reader = stream.getReader(); + const stringDecoder = createStringDecoder(); + let stringBuffer = ''; + function progress({ + done, + value, + }: { + done: boolean, + value: ?any, + ... + }): void | Promise { + const buffer: Uint8Array = (value: any); + stringBuffer += done + ? readFinalStringChunk(stringDecoder, new Uint8Array(0)) + : readPartialStringChunk(stringDecoder, buffer); + const messages = stringBuffer.split('\n'); + for (let i = 0; i < messages.length - 1; i++) { + resolveDebugMessage(request, messages[i]); + } + stringBuffer = messages[messages.length - 1]; + if (done) { + closeDebugChannel(request); + return; + } + return reader.read().then(progress).catch(error); + } + function error(e: any) { + abort( + request, + new Error('Lost connection to the Debug Channel.', { + cause: e, + }), + ); + } + reader.read().then(progress).catch(error); +} + function renderToReadableStream( model: ReactClientValue, turbopackMap: ClientManifest, - options?: Options & { + options?: Omit & { + debugChannel?: {readable?: ReadableStream, ...}, signal?: AbortSignal, }, ): ReadableStream { + const debugChannelReadable = + __DEV__ && options && options.debugChannel + ? options.debugChannel.readable + : undefined; const request = createRequest( model, turbopackMap, @@ -178,6 +301,7 @@ function renderToReadableStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + debugChannelReadable !== undefined, ); if (options && options.signal) { const signal = options.signal; @@ -191,6 +315,9 @@ function renderToReadableStream( signal.addEventListener('abort', listener); } } + if (debugChannelReadable !== undefined) { + startReadingFromDebugChannelReadableStream(request, debugChannelReadable); + } let writable: Writable; const stream = new ReadableStream( { @@ -271,6 +398,7 @@ function prerenderToNodeStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + false, ); if (options && options.signal) { const signal = options.signal; @@ -334,6 +462,7 @@ function prerender( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + false, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js index ee319beca18ef..0a3b6cedc8224 100644 --- a/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js @@ -12,6 +12,7 @@ import type {Thenable} from 'shared/ReactTypes.js'; import type { Response as FlightResponse, FindSourceMapURLCallback, + DebugChannelCallback, } from 'react-client/src/ReactFlightClient'; import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; @@ -42,12 +43,31 @@ type CallServerCallback = (string, args: A) => Promise; export type Options = { callServer?: CallServerCallback, + debugChannel?: {writable?: WritableStream, ...}, temporaryReferences?: TemporaryReferenceSet, findSourceMapURL?: FindSourceMapURLCallback, replayConsoleLogs?: boolean, environmentName?: string, }; +function createDebugCallbackFromWritableStream( + debugWritable: WritableStream, +): DebugChannelCallback { + const textEncoder = new TextEncoder(); + const writer = debugWritable.getWriter(); + return message => { + if (message === '') { + writer.close(); + } else { + // Note: It's important that this function doesn't close over the Response object or it can't be GC:ed. + // Therefore, we can't report errors from this write back to the Response object. + if (__DEV__) { + writer.write(textEncoder.encode(message + '\n')).catch(console.error); + } + } + }; +} + function createResponseFromOptions(options: void | Options) { return createResponse( null, @@ -66,6 +86,12 @@ function createResponseFromOptions(options: void | Options) { __DEV__ && options && options.environmentName ? options.environmentName : undefined, + __DEV__ && + options && + options.debugChannel !== undefined && + options.debugChannel.writable !== undefined + ? createDebugCallbackFromWritableStream(options.debugChannel.writable) + : undefined, ); } diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js index 7954417b95a25..e2576eafecc19 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerBrowser.js @@ -7,7 +7,10 @@ * @flow */ -import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; +import type { + Request, + ReactClientValue, +} from 'react-server/src/ReactFlightServer'; import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigWebpackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; @@ -19,6 +22,8 @@ import { startFlowing, stopFlowing, abort, + resolveDebugMessage, + closeDebugChannel, } from 'react-server/src/ReactFlightServer'; import { @@ -38,6 +43,12 @@ export { createClientModuleProxy, } from '../ReactFlightWebpackReferences'; +import { + createStringDecoder, + readPartialStringChunk, + readFinalStringChunk, +} from 'react-client/src/ReactFlightClientStreamConfigWeb'; + import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -45,6 +56,7 @@ export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTem export type {TemporaryReferenceSet}; type Options = { + debugChannel?: {readable?: ReadableStream, ...}, environmentName?: string | (() => string), filterStackFrame?: (url: string, functionName: string) => boolean, identifierPrefix?: string, @@ -54,11 +66,56 @@ type Options = { onPostpone?: (reason: string) => void, }; +function startReadingFromDebugChannelReadableStream( + request: Request, + stream: ReadableStream, +): void { + const reader = stream.getReader(); + const stringDecoder = createStringDecoder(); + let stringBuffer = ''; + function progress({ + done, + value, + }: { + done: boolean, + value: ?any, + ... + }): void | Promise { + const buffer: Uint8Array = (value: any); + stringBuffer += done + ? readFinalStringChunk(stringDecoder, new Uint8Array(0)) + : readPartialStringChunk(stringDecoder, buffer); + const messages = stringBuffer.split('\n'); + for (let i = 0; i < messages.length - 1; i++) { + resolveDebugMessage(request, messages[i]); + } + stringBuffer = messages[messages.length - 1]; + if (done) { + closeDebugChannel(request); + return; + } + return reader.read().then(progress).catch(error); + } + function error(e: any) { + abort( + request, + new Error('Lost connection to the Debug Channel.', { + cause: e, + }), + ); + } + reader.read().then(progress).catch(error); +} + function renderToReadableStream( model: ReactClientValue, webpackMap: ClientManifest, options?: Options, ): ReadableStream { + const debugChannelReadable = + __DEV__ && options && options.debugChannel + ? options.debugChannel.readable + : undefined; const request = createRequest( model, webpackMap, @@ -68,6 +125,7 @@ function renderToReadableStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + debugChannelReadable !== undefined, ); if (options && options.signal) { const signal = options.signal; @@ -81,6 +139,9 @@ function renderToReadableStream( signal.addEventListener('abort', listener); } } + if (debugChannelReadable !== undefined) { + startReadingFromDebugChannelReadableStream(request, debugChannelReadable); + } const stream = new ReadableStream( { type: 'bytes', @@ -116,9 +177,6 @@ function prerender( const stream = new ReadableStream( { type: 'bytes', - start: (controller): ?Promise => { - startWork(request); - }, pull: (controller): ?Promise => { startFlowing(request, controller); }, @@ -143,6 +201,7 @@ function prerender( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + false, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js index 46cb61fc4c0e9..e871cfb9e9edb 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js @@ -7,7 +7,10 @@ * @flow */ -import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; +import type { + Request, + ReactClientValue, +} from 'react-server/src/ReactFlightServer'; import type {Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerConfigWebpackBundler'; import type {ServerManifest} from 'react-client/src/ReactFlightClientConfig'; @@ -21,6 +24,8 @@ import { startFlowing, stopFlowing, abort, + resolveDebugMessage, + closeDebugChannel, } from 'react-server/src/ReactFlightServer'; import { @@ -43,6 +48,12 @@ export { createClientModuleProxy, } from '../ReactFlightWebpackReferences'; +import { + createStringDecoder, + readPartialStringChunk, + readFinalStringChunk, +} from 'react-client/src/ReactFlightClientStreamConfigWeb'; + import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -50,6 +61,7 @@ export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTem export type {TemporaryReferenceSet}; type Options = { + debugChannel?: {readable?: ReadableStream, ...}, environmentName?: string | (() => string), filterStackFrame?: (url: string, functionName: string) => boolean, identifierPrefix?: string, @@ -59,11 +71,56 @@ type Options = { onPostpone?: (reason: string) => void, }; +function startReadingFromDebugChannelReadableStream( + request: Request, + stream: ReadableStream, +): void { + const reader = stream.getReader(); + const stringDecoder = createStringDecoder(); + let stringBuffer = ''; + function progress({ + done, + value, + }: { + done: boolean, + value: ?any, + ... + }): void | Promise { + const buffer: Uint8Array = (value: any); + stringBuffer += done + ? readFinalStringChunk(stringDecoder, new Uint8Array(0)) + : readPartialStringChunk(stringDecoder, buffer); + const messages = stringBuffer.split('\n'); + for (let i = 0; i < messages.length - 1; i++) { + resolveDebugMessage(request, messages[i]); + } + stringBuffer = messages[messages.length - 1]; + if (done) { + closeDebugChannel(request); + return; + } + return reader.read().then(progress).catch(error); + } + function error(e: any) { + abort( + request, + new Error('Lost connection to the Debug Channel.', { + cause: e, + }), + ); + } + reader.read().then(progress).catch(error); +} + function renderToReadableStream( model: ReactClientValue, webpackMap: ClientManifest, options?: Options, ): ReadableStream { + const debugChannelReadable = + __DEV__ && options && options.debugChannel + ? options.debugChannel.readable + : undefined; const request = createRequest( model, webpackMap, @@ -73,6 +130,7 @@ function renderToReadableStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + debugChannelReadable !== undefined, ); if (options && options.signal) { const signal = options.signal; @@ -86,6 +144,9 @@ function renderToReadableStream( signal.addEventListener('abort', listener); } } + if (debugChannelReadable !== undefined) { + startReadingFromDebugChannelReadableStream(request, debugChannelReadable); + } const stream = new ReadableStream( { type: 'bytes', @@ -145,6 +206,7 @@ function prerender( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + false, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js index cede8a46d69ad..7bac80292fe5e 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js @@ -18,6 +18,8 @@ import type {Busboy} from 'busboy'; import type {Writable} from 'stream'; import type {Thenable} from 'shared/ReactTypes'; +import type {Duplex} from 'stream'; + import {Readable} from 'stream'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; @@ -29,6 +31,8 @@ import { startFlowing, stopFlowing, abort, + resolveDebugMessage, + closeDebugChannel, } from 'react-server/src/ReactFlightServer'; import { @@ -54,6 +58,12 @@ export { createClientModuleProxy, } from '../ReactFlightWebpackReferences'; +import { + createStringDecoder, + readPartialStringChunk, + readFinalStringChunk, +} from 'react-client/src/ReactFlightClientStreamConfigNode'; + import {textEncoder} from 'react-server/src/ReactServerStreamConfigNode'; import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; @@ -73,7 +83,69 @@ function createCancelHandler(request: Request, reason: string) { }; } +function startReadingFromDebugChannelReadable( + request: Request, + stream: Readable | WebSocket, +): void { + const stringDecoder = createStringDecoder(); + let lastWasPartial = false; + let stringBuffer = ''; + function onData(chunk: string | Uint8Array) { + if (typeof chunk === 'string') { + if (lastWasPartial) { + stringBuffer += readFinalStringChunk(stringDecoder, new Uint8Array(0)); + lastWasPartial = false; + } + stringBuffer += chunk; + } else { + const buffer: Uint8Array = (chunk: any); + stringBuffer += readPartialStringChunk(stringDecoder, buffer); + lastWasPartial = true; + } + const messages = stringBuffer.split('\n'); + for (let i = 0; i < messages.length - 1; i++) { + resolveDebugMessage(request, messages[i]); + } + stringBuffer = messages[messages.length - 1]; + } + function onError(error: mixed) { + abort( + request, + new Error('Lost connection to the Debug Channel.', { + cause: error, + }), + ); + } + function onClose() { + closeDebugChannel(request); + } + if ( + // $FlowFixMe[method-unbinding] + typeof stream.addEventListener === 'function' && + // $FlowFixMe[method-unbinding] + typeof stream.binaryType === 'string' + ) { + const ws: WebSocket = (stream: any); + ws.binaryType = 'arraybuffer'; + ws.addEventListener('message', event => { + // $FlowFixMe + onData(event.data); + }); + ws.addEventListener('error', event => { + // $FlowFixMe + onError(event.error); + }); + ws.addEventListener('close', onClose); + } else { + const readable: Readable = (stream: any); + readable.on('data', onData); + readable.on('error', onError); + readable.on('end', onClose); + } +} + type Options = { + debugChannel?: Readable | Duplex | WebSocket, environmentName?: string | (() => string), filterStackFrame?: (url: string, functionName: string) => boolean, onError?: (error: mixed) => void, @@ -92,6 +164,7 @@ function renderToPipeableStream( webpackMap: ClientManifest, options?: Options, ): PipeableStream { + const debugChannel = __DEV__ && options ? options.debugChannel : undefined; const request = createRequest( model, webpackMap, @@ -101,9 +174,13 @@ function renderToPipeableStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + debugChannel !== undefined, ); let hasStartedFlowing = false; startWork(request); + if (debugChannel !== undefined) { + startReadingFromDebugChannelReadable(request, debugChannel); + } return { pipe(destination: T): T { if (hasStartedFlowing) { @@ -162,13 +239,59 @@ function createFakeWritableFromReadableStreamController( }: any); } +function startReadingFromDebugChannelReadableStream( + request: Request, + stream: ReadableStream, +): void { + const reader = stream.getReader(); + const stringDecoder = createStringDecoder(); + let stringBuffer = ''; + function progress({ + done, + value, + }: { + done: boolean, + value: ?any, + ... + }): void | Promise { + const buffer: Uint8Array = (value: any); + stringBuffer += done + ? readFinalStringChunk(stringDecoder, new Uint8Array(0)) + : readPartialStringChunk(stringDecoder, buffer); + const messages = stringBuffer.split('\n'); + for (let i = 0; i < messages.length - 1; i++) { + resolveDebugMessage(request, messages[i]); + } + stringBuffer = messages[messages.length - 1]; + if (done) { + closeDebugChannel(request); + return; + } + return reader.read().then(progress).catch(error); + } + function error(e: any) { + abort( + request, + new Error('Lost connection to the Debug Channel.', { + cause: e, + }), + ); + } + reader.read().then(progress).catch(error); +} + function renderToReadableStream( model: ReactClientValue, webpackMap: ClientManifest, - options?: Options & { + options?: Omit & { + debugChannel?: {readable?: ReadableStream, ...}, signal?: AbortSignal, }, ): ReadableStream { + const debugChannelReadable = + __DEV__ && options && options.debugChannel + ? options.debugChannel.readable + : undefined; const request = createRequest( model, webpackMap, @@ -178,6 +301,7 @@ function renderToReadableStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + debugChannelReadable !== undefined, ); if (options && options.signal) { const signal = options.signal; @@ -191,6 +315,9 @@ function renderToReadableStream( signal.addEventListener('abort', listener); } } + if (debugChannelReadable !== undefined) { + startReadingFromDebugChannelReadableStream(request, debugChannelReadable); + } let writable: Writable; const stream = new ReadableStream( { @@ -271,6 +398,7 @@ function prerenderToNodeStream( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + false, ); if (options && options.signal) { const signal = options.signal; @@ -334,6 +462,7 @@ function prerender( options ? options.temporaryReferences : undefined, __DEV__ && options ? options.environmentName : undefined, __DEV__ && options ? options.filterStackFrame : undefined, + false, ); if (options && options.signal) { const signal = options.signal; diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index f07e3c6304582..df309ac856b25 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -404,6 +404,13 @@ type Task = { interface Reference {} +type ReactClientReference = Reference & ReactClientValue; + +type DeferredDebugStore = { + retained: Map, + existing: Map, +}; + const OPENING = 10; const OPEN = 11; const ABORTING = 12; @@ -451,6 +458,7 @@ export type Request = { filterStackFrame: (url: string, functionName: string) => boolean, didWarnForKey: null | WeakSet, writtenDebugObjects: WeakMap, + deferredDebugObjects: null | DeferredDebugStore, }; const { @@ -495,13 +503,14 @@ function RequestInstance( model: ReactClientValue, bundlerConfig: ClientManifest, onError: void | ((error: mixed) => ?string), - identifierPrefix?: string, onPostpone: void | ((reason: string) => void), + onAllReady: () => void, + onFatalError: (error: mixed) => void, + identifierPrefix?: string, temporaryReferences: void | TemporaryReferenceSet, environmentName: void | string | (() => string), // DEV-only filterStackFrame: void | ((url: string, functionName: string) => boolean), // DEV-only - onAllReady: () => void, - onFatalError: (error: mixed) => void, + keepDebugAlive: boolean, // DEV-only ) { if ( ReactSharedInternals.A !== null && @@ -571,6 +580,12 @@ function RequestInstance( : filterStackFrame; this.didWarnForKey = null; this.writtenDebugObjects = new WeakMap(); + this.deferredDebugObjects = keepDebugAlive + ? { + retained: new Map(), + existing: new Map(), + } + : null; } let timeOrigin: number; @@ -615,6 +630,7 @@ export function createRequest( temporaryReferences: void | TemporaryReferenceSet, environmentName: void | string | (() => string), // DEV-only filterStackFrame: void | ((url: string, functionName: string) => boolean), // DEV-only + keepDebugAlive: boolean, // DEV-only ): Request { if (__DEV__) { resetOwnerStackLimit(); @@ -626,13 +642,14 @@ export function createRequest( model, bundlerConfig, onError, - identifierPrefix, onPostpone, + noop, + noop, + identifierPrefix, temporaryReferences, environmentName, filterStackFrame, - noop, - noop, + keepDebugAlive, ); } @@ -647,6 +664,7 @@ export function createPrerenderRequest( temporaryReferences: void | TemporaryReferenceSet, environmentName: void | string | (() => string), // DEV-only filterStackFrame: void | ((url: string, functionName: string) => boolean), // DEV-only + keepDebugAlive: boolean, // DEV-only ): Request { if (__DEV__) { resetOwnerStackLimit(); @@ -658,13 +676,14 @@ export function createPrerenderRequest( model, bundlerConfig, onError, - identifierPrefix, onPostpone, + onAllReady, + onFatalError, + identifierPrefix, temporaryReferences, environmentName, filterStackFrame, - onAllReady, - onFatalError, + keepDebugAlive, ); } @@ -2331,7 +2350,21 @@ function serializeSymbolReference(name: string): string { return '$S' + name; } -function serializeLimitedObject(): string { +function serializeDeferredObject( + request: Request, + value: ReactClientReference | string, +): string { + const deferredDebugObjects = request.deferredDebugObjects; + if (deferredDebugObjects !== null) { + // This client supports a long lived connection. We can assign this object + // an ID to be lazy loaded later. + // This keeps the connection alive until we ask for it or release it. + request.pendingChunks++; + const id = request.nextChunkId++; + deferredDebugObjects.existing.set(value, id); + deferredDebugObjects.retained.set(id, value); + return '$Y' + id.toString(16); + } return '$Y'; } @@ -4058,12 +4091,25 @@ function renderDebugModel( if (counter.objectLimit <= 0 && !doNotLimit.has(value)) { // We've reached our max number of objects to serialize across the wire so we serialize this - // as a marker so that the client can error when this is accessed by the console. - return serializeLimitedObject(); + // as a marker so that the client can error or lazy load this when accessed by the console. + return serializeDeferredObject(request, value); } counter.objectLimit--; + const deferredDebugObjects = request.deferredDebugObjects; + if (deferredDebugObjects !== null) { + const deferredId = deferredDebugObjects.existing.get(value); + // We earlier deferred this same object. We're now going to eagerly emit it so let's emit it + // at the same ID that we already used to refer to it. + if (deferredId !== undefined) { + deferredDebugObjects.existing.delete(value); + deferredDebugObjects.retained.delete(deferredId); + emitOutlinedDebugModelChunk(request, deferredId, counter, value); + return serializeByValueID(deferredId); + } + } + switch ((value: any).$$typeof) { case REACT_ELEMENT_TYPE: { const element: ReactElement = (value: any); @@ -4235,6 +4281,13 @@ function renderDebugModel( } } if (value.length >= 1024) { + // Large strings are counted towards the object limit. + if (counter.objectLimit <= 0) { + // We've reached our max number of objects to serialize across the wire so we serialize this + // as a marker so that the client can error or lazy load this when accessed by the console. + return serializeDeferredObject(request, value); + } + counter.objectLimit--; // For large strings, we encode them outside the JSON payload so that we // don't have to double encode and double parse the strings. This can also // be more compact in case the string has a lot of escaped characters. @@ -5254,3 +5307,82 @@ export function abort(request: Request, reason: mixed): void { fatalError(request, error); } } + +function fromHex(str: string): number { + return parseInt(str, 16); +} + +export function resolveDebugMessage(request: Request, message: string): void { + if (!__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'resolveDebugMessage should never be called in production mode. This is a bug in React.', + ); + } + const deferredDebugObjects = request.deferredDebugObjects; + if (deferredDebugObjects === null) { + throw new Error( + "resolveDebugMessage/closeDebugChannel should not be called for a Request that wasn't kept alive. This is a bug in React.", + ); + } + // This function lets the client ask for more data lazily through the debug channel. + const command = message.charCodeAt(0); + const ids = message.slice(2).split(',').map(fromHex); + switch (command) { + case 82 /* "R" */: + // Release IDs + for (let i = 0; i < ids.length; i++) { + const id = ids[i]; + const retainedValue = deferredDebugObjects.retained.get(id); + if (retainedValue !== undefined) { + // We're no longer blocked on this. We won't emit it. + request.pendingChunks--; + deferredDebugObjects.retained.delete(id); + deferredDebugObjects.existing.delete(retainedValue); + enqueueFlush(request); + } + } + break; + case 81 /* "Q" */: + // Query IDs + for (let i = 0; i < ids.length; i++) { + const id = ids[i]; + const retainedValue = deferredDebugObjects.retained.get(id); + if (retainedValue !== undefined) { + // If we still have this object, and haven't emitted it before, emit it on the stream. + const counter = {objectLimit: 10}; + emitOutlinedDebugModelChunk(request, id, counter, retainedValue); + enqueueFlush(request); + } + } + break; + default: + throw new Error( + 'Unknown command. The debugChannel was not wired up properly.', + ); + } +} + +export function closeDebugChannel(request: Request): void { + if (!__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'closeDebugChannel should never be called in production mode. This is a bug in React.', + ); + } + // This clears all remaining deferred objects, potentially resulting in the completion of the Request. + const deferredDebugObjects = request.deferredDebugObjects; + if (deferredDebugObjects === null) { + throw new Error( + "resolveDebugMessage/closeDebugChannel should not be called for a Request that wasn't kept alive. This is a bug in React.", + ); + } + deferredDebugObjects.retained.forEach((value, id) => { + request.pendingChunks--; + deferredDebugObjects.retained.delete(id); + deferredDebugObjects.existing.delete(value); + }); + enqueueFlush(request); +} diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index ef0bad4c09833..a9867c154c66c 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -548,5 +548,7 @@ "560": "Cannot use a startGestureTransition() with a comment node root.", "561": "This rendered a large document (>%s kB) without any Suspense boundaries around most of it. That can delay initial paint longer than necessary. To improve load performance, add a or around the content you expect to be below the header or below the fold. In the meantime, the content will deopt to paint arbitrary incomplete pieces of HTML.", "562": "The render was aborted due to a fatal error.", - "563": "This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources." + "563": "This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources.", + "564": "Unknown command. The debugChannel was not wired up properly.", + "565": "resolveDebugMessage/closeDebugChannel should not be called for a Request that wasn't kept alive. This is a bug in React." }