Skip to content

Commit 0bc71e6

Browse files
authored
[Flight] Add debugChannel option to Edge and Node clients (facebook#34236)
When a debug channel is used between the Flight server and a browser Flight client, we want to allow the same RSC stream to be used for server-side rendering. To support this, the Edge and Node Flight clients also need to accept a `debugChannel` option. Without it, debug information would be missing (e.g. for SSR error stacks), and in some cases this could result in `Connection closed` errors. This PR adds support for the `debugChannel` option in the Edge and Node clients for ESM, Parcel, Turbopack, and Webpack. Unlike the browser clients, these clients only support a one-way channel, since the Flight server’s return protocol is not designed for multiple clients. The implementation follows the approach used in the browser clients, but excludes the writable parts.
1 parent 3e20dc8 commit 0bc71e6

File tree

11 files changed

+635
-66
lines changed

11 files changed

+635
-66
lines changed

packages/react-server-dom-esm/src/client/ReactFlightDOMClientNode.js

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,38 @@ export type Options = {
5656
findSourceMapURL?: FindSourceMapURLCallback,
5757
replayConsoleLogs?: boolean,
5858
environmentName?: string,
59+
// For the Node.js client we only support a single-direction debug channel.
60+
debugChannel?: Readable,
5961
};
6062

63+
function startReadingFromStream(
64+
response: Response,
65+
stream: Readable,
66+
isSecondaryStream: boolean,
67+
): void {
68+
const streamState = createStreamState();
69+
70+
stream.on('data', chunk => {
71+
if (typeof chunk === 'string') {
72+
processStringChunk(response, streamState, chunk);
73+
} else {
74+
processBinaryChunk(response, streamState, chunk);
75+
}
76+
});
77+
78+
stream.on('error', error => {
79+
reportGlobalError(response, error);
80+
});
81+
82+
stream.on('end', () => {
83+
// If we're the secondary stream, then we don't close the response until the
84+
// debug channel closes.
85+
if (!isSecondaryStream) {
86+
close(response);
87+
}
88+
});
89+
}
90+
6191
function createFromNodeStream<T>(
6292
stream: Readable,
6393
moduleRootPath: string,
@@ -80,18 +110,14 @@ function createFromNodeStream<T>(
80110
? options.environmentName
81111
: undefined,
82112
);
83-
const streamState = createStreamState();
84-
stream.on('data', chunk => {
85-
if (typeof chunk === 'string') {
86-
processStringChunk(response, streamState, chunk);
87-
} else {
88-
processBinaryChunk(response, streamState, chunk);
89-
}
90-
});
91-
stream.on('error', error => {
92-
reportGlobalError(response, error);
93-
});
94-
stream.on('end', () => close(response));
113+
114+
if (__DEV__ && options && options.debugChannel) {
115+
startReadingFromStream(response, options.debugChannel, false);
116+
startReadingFromStream(response, stream, true);
117+
} else {
118+
startReadingFromStream(response, stream, false);
119+
}
120+
95121
return getRoot(response);
96122
}
97123

packages/react-server-dom-parcel/src/client/ReactFlightDOMClientEdge.js

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ export type Options = {
7676
temporaryReferences?: TemporaryReferenceSet,
7777
replayConsoleLogs?: boolean,
7878
environmentName?: string,
79+
// For the Edge client we only support a single-direction debug channel.
80+
debugChannel?: {readable?: ReadableStream, ...},
7981
};
8082

8183
function createResponseFromOptions(options?: Options) {
@@ -100,6 +102,7 @@ function createResponseFromOptions(options?: Options) {
100102
function startReadingFromStream(
101103
response: FlightResponse,
102104
stream: ReadableStream,
105+
isSecondaryStream: boolean,
103106
): void {
104107
const streamState = createStreamState();
105108
const reader = stream.getReader();
@@ -112,7 +115,11 @@ function startReadingFromStream(
112115
...
113116
}): void | Promise<void> {
114117
if (done) {
115-
close(response);
118+
// If we're the secondary stream, then we don't close the response until
119+
// the debug channel closes.
120+
if (!isSecondaryStream) {
121+
close(response);
122+
}
116123
return;
117124
}
118125
const buffer: Uint8Array = (value: any);
@@ -130,7 +137,19 @@ export function createFromReadableStream<T>(
130137
options?: Options,
131138
): Thenable<T> {
132139
const response: FlightResponse = createResponseFromOptions(options);
133-
startReadingFromStream(response, stream);
140+
141+
if (
142+
__DEV__ &&
143+
options &&
144+
options.debugChannel &&
145+
options.debugChannel.readable
146+
) {
147+
startReadingFromStream(response, options.debugChannel.readable, false);
148+
startReadingFromStream(response, stream, true);
149+
} else {
150+
startReadingFromStream(response, stream, false);
151+
}
152+
134153
return getRoot(response);
135154
}
136155

@@ -141,7 +160,17 @@ export function createFromFetch<T>(
141160
const response: FlightResponse = createResponseFromOptions(options);
142161
promiseForResponse.then(
143162
function (r) {
144-
startReadingFromStream(response, (r.body: any));
163+
if (
164+
__DEV__ &&
165+
options &&
166+
options.debugChannel &&
167+
options.debugChannel.readable
168+
) {
169+
startReadingFromStream(response, options.debugChannel.readable, false);
170+
startReadingFromStream(response, (r.body: any), true);
171+
} else {
172+
startReadingFromStream(response, (r.body: any), false);
173+
}
145174
},
146175
function (e) {
147176
reportGlobalError(response, e);

packages/react-server-dom-parcel/src/client/ReactFlightDOMClientNode.js

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,38 @@ export type Options = {
5252
encodeFormAction?: EncodeFormActionCallback,
5353
replayConsoleLogs?: boolean,
5454
environmentName?: string,
55+
// For the Node.js client we only support a single-direction debug channel.
56+
debugChannel?: Readable,
5557
};
5658

59+
function startReadingFromStream(
60+
response: Response,
61+
stream: Readable,
62+
isSecondaryStream: boolean,
63+
): void {
64+
const streamState = createStreamState();
65+
66+
stream.on('data', chunk => {
67+
if (typeof chunk === 'string') {
68+
processStringChunk(response, streamState, chunk);
69+
} else {
70+
processBinaryChunk(response, streamState, chunk);
71+
}
72+
});
73+
74+
stream.on('error', error => {
75+
reportGlobalError(response, error);
76+
});
77+
78+
stream.on('end', () => {
79+
// If we're the secondary stream, then we don't close the response until the
80+
// debug channel closes.
81+
if (!isSecondaryStream) {
82+
close(response);
83+
}
84+
});
85+
}
86+
5787
export function createFromNodeStream<T>(
5888
stream: Readable,
5989
options?: Options,
@@ -72,17 +102,13 @@ export function createFromNodeStream<T>(
72102
? options.environmentName
73103
: undefined,
74104
);
75-
const streamState = createStreamState();
76-
stream.on('data', chunk => {
77-
if (typeof chunk === 'string') {
78-
processStringChunk(response, streamState, chunk);
79-
} else {
80-
processBinaryChunk(response, streamState, chunk);
81-
}
82-
});
83-
stream.on('error', error => {
84-
reportGlobalError(response, error);
85-
});
86-
stream.on('end', () => close(response));
105+
106+
if (__DEV__ && options && options.debugChannel) {
107+
startReadingFromStream(response, options.debugChannel, false);
108+
startReadingFromStream(response, stream, true);
109+
} else {
110+
startReadingFromStream(response, stream, false);
111+
}
112+
87113
return getRoot(response);
88114
}

packages/react-server-dom-turbopack/src/__tests__/ReactFlightTurbopackDOMEdge-test.js

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,28 @@
1212
// Polyfills for test environment
1313
global.ReadableStream =
1414
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
15+
global.WritableStream =
16+
require('web-streams-polyfill/ponyfill/es6').WritableStream;
1517
global.TextEncoder = require('util').TextEncoder;
1618
global.TextDecoder = require('util').TextDecoder;
1719

18-
// Don't wait before processing work on the server.
19-
// TODO: we can replace this with FlightServer.act().
20-
global.setTimeout = cb => cb();
21-
2220
let clientExports;
2321
let turbopackMap;
2422
let turbopackModules;
2523
let React;
24+
let ReactServer;
2625
let ReactDOMServer;
2726
let ReactServerDOMServer;
2827
let ReactServerDOMClient;
2928
let use;
29+
let serverAct;
3030

3131
describe('ReactFlightTurbopackDOMEdge', () => {
3232
beforeEach(() => {
3333
jest.resetModules();
3434

35+
serverAct = require('internal-test-utils').serverAct;
36+
3537
// Simulate the condition resolution
3638
jest.mock('react', () => require('react/react.react-server'));
3739
jest.mock('react-server-dom-turbopack/server', () =>
@@ -43,6 +45,7 @@ describe('ReactFlightTurbopackDOMEdge', () => {
4345
turbopackMap = TurbopackMock.turbopackMap;
4446
turbopackModules = TurbopackMock.turbopackModules;
4547

48+
ReactServer = require('react');
4649
ReactServerDOMServer = require('react-server-dom-turbopack/server.edge');
4750

4851
jest.resetModules();
@@ -66,6 +69,15 @@ describe('ReactFlightTurbopackDOMEdge', () => {
6669
}
6770
}
6871

72+
function normalizeCodeLocInfo(str) {
73+
return (
74+
str &&
75+
str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) {
76+
return ' in ' + name + (/\d/.test(m) ? ' (at **)' : '');
77+
})
78+
);
79+
}
80+
6981
it('should allow an alternative module mapping to be used for SSR', async () => {
7082
function ClientComponent() {
7183
return <span>Client Component</span>;
@@ -92,9 +104,8 @@ describe('ReactFlightTurbopackDOMEdge', () => {
92104
return <ClientComponentOnTheClient />;
93105
}
94106

95-
const stream = ReactServerDOMServer.renderToReadableStream(
96-
<App />,
97-
turbopackMap,
107+
const stream = await serverAct(() =>
108+
ReactServerDOMServer.renderToReadableStream(<App />, turbopackMap),
98109
);
99110
const response = ReactServerDOMClient.createFromReadableStream(stream, {
100111
serverConsumerManifest: {
@@ -107,10 +118,98 @@ describe('ReactFlightTurbopackDOMEdge', () => {
107118
return use(response);
108119
}
109120

110-
const ssrStream = await ReactDOMServer.renderToReadableStream(
111-
<ClientRoot />,
121+
const ssrStream = await serverAct(() =>
122+
ReactDOMServer.renderToReadableStream(<ClientRoot />),
112123
);
113124
const result = await readResult(ssrStream);
114125
expect(result).toEqual('<span>Client Component</span>');
115126
});
127+
128+
// @gate __DEV__
129+
it('can transport debug info through a separate debug channel', async () => {
130+
function Thrower() {
131+
throw new Error('ssr-throw');
132+
}
133+
134+
const ClientComponentOnTheClient = clientExports(
135+
Thrower,
136+
123,
137+
'path/to/chunk.js',
138+
);
139+
140+
const ClientComponentOnTheServer = clientExports(Thrower);
141+
142+
function App() {
143+
return ReactServer.createElement(
144+
ReactServer.Suspense,
145+
null,
146+
ReactServer.createElement(ClientComponentOnTheClient, null),
147+
);
148+
}
149+
150+
let debugReadableStreamController;
151+
152+
const debugReadableStream = new ReadableStream({
153+
start(controller) {
154+
debugReadableStreamController = controller;
155+
},
156+
});
157+
158+
const rscStream = await serverAct(() =>
159+
ReactServerDOMServer.renderToReadableStream(
160+
ReactServer.createElement(App, null),
161+
turbopackMap,
162+
{
163+
debugChannel: {
164+
writable: new WritableStream({
165+
write(chunk) {
166+
debugReadableStreamController.enqueue(chunk);
167+
},
168+
}),
169+
},
170+
},
171+
),
172+
);
173+
174+
function ClientRoot({response}) {
175+
return use(response);
176+
}
177+
178+
const serverConsumerManifest = {
179+
moduleMap: {
180+
[turbopackMap[ClientComponentOnTheClient.$$id].id]: {
181+
'*': turbopackMap[ClientComponentOnTheServer.$$id],
182+
},
183+
},
184+
moduleLoading: null,
185+
};
186+
187+
const response = ReactServerDOMClient.createFromReadableStream(rscStream, {
188+
serverConsumerManifest,
189+
debugChannel: {readable: debugReadableStream},
190+
});
191+
192+
let ownerStack;
193+
194+
const ssrStream = await serverAct(() =>
195+
ReactDOMServer.renderToReadableStream(
196+
<ClientRoot response={response} />,
197+
{
198+
onError(err, errorInfo) {
199+
ownerStack = React.captureOwnerStack
200+
? React.captureOwnerStack()
201+
: null;
202+
},
203+
},
204+
),
205+
);
206+
207+
const result = await readResult(ssrStream);
208+
209+
expect(normalizeCodeLocInfo(ownerStack)).toBe('\n in App (at **)');
210+
211+
expect(result).toContain(
212+
'Switched to client rendering because the server rendering errored:\n\nssr-throw',
213+
);
214+
});
116215
});

0 commit comments

Comments
 (0)