Skip to content

Commit fe90e47

Browse files
unstubbablelubieowoce
authored andcommitted
Send dynamic validation errors to browser via WebSocket
1 parent 596bc0c commit fe90e47

File tree

17 files changed

+242
-86
lines changed

17 files changed

+242
-86
lines changed

packages/next/errors.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -943,5 +943,8 @@
943943
"942": "Unexpected stream chunk while in Before stage",
944944
"943": "getFlightStream should always receive a ReadableStream when using the edge runtime",
945945
"944": "nodeStreamFromReadableStream cannot be used in the edge runtime",
946-
"945": "createNodeStreamFromChunks cannot be used in the edge runtime"
946+
"945": "createNodeStreamFromChunks cannot be used in the edge runtime",
947+
"946": "Failed to deserialize errors.",
948+
"947": "Expected `sendErrorsToBrowser` to be defined in renderOpts.",
949+
"948": "Failed to serialize errors."
947950
}

packages/next/src/build/templates/app-page.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,7 @@ export async function handler(
545545
setCacheStatus: routerServerContext?.setCacheStatus,
546546
setIsrStatus: routerServerContext?.setIsrStatus,
547547
setReactDebugChannel: routerServerContext?.setReactDebugChannel,
548+
sendErrorsToBrowser: routerServerContext?.sendErrorsToBrowser,
548549

549550
dir:
550551
process.env.NEXT_RUNTIME === 'nodejs'

packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ import {
3838
} from '../../../components/app-router-instance'
3939
import { InvariantError } from '../../../../shared/lib/invariant-error'
4040
import { getOrCreateDebugChannelReadableWriterPair } from '../../debug-channel'
41+
// eslint-disable-next-line import/no-extraneous-dependencies
42+
import { createFromReadableStream } from 'react-server-dom-webpack/client.browser'
43+
import { findSourceMapURL } from '../../../app-find-source-map-url'
4144

4245
export interface StaticIndicatorState {
4346
pathname: string | null
@@ -504,6 +507,29 @@ export function processMessage(
504507
dispatcher.onCacheIndicator(message.state)
505508
return
506509
}
510+
case HMR_MESSAGE_SENT_TO_BROWSER.ERRORS_TO_SHOW_IN_BROWSER: {
511+
createFromReadableStream<Error[]>(
512+
new ReadableStream({
513+
start(controller) {
514+
controller.enqueue(message.serializedErrors)
515+
controller.close()
516+
},
517+
}),
518+
{ findSourceMapURL }
519+
).then(
520+
(errors) => {
521+
for (const error of errors) {
522+
console.error(error)
523+
}
524+
},
525+
(err) => {
526+
console.error(
527+
new Error('Failed to deserialize errors.', { cause: err })
528+
)
529+
}
530+
)
531+
return
532+
}
507533
case HMR_MESSAGE_SENT_TO_BROWSER.MIDDLEWARE_CHANGES:
508534
case HMR_MESSAGE_SENT_TO_BROWSER.CLIENT_CHANGES:
509535
case HMR_MESSAGE_SENT_TO_BROWSER.SERVER_ONLY_CHANGES:

packages/next/src/client/dev/hot-reloader/app/web-socket.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,14 @@ function parseBinaryMessage(data: ArrayBuffer): HmrMessageSentToBrowser {
223223
const messageType = view.getUint8(0)
224224

225225
switch (messageType) {
226+
case HMR_MESSAGE_SENT_TO_BROWSER.ERRORS_TO_SHOW_IN_BROWSER: {
227+
const serializedErrors = new Uint8Array(data, 1)
228+
229+
return {
230+
type: HMR_MESSAGE_SENT_TO_BROWSER.ERRORS_TO_SHOW_IN_BROWSER,
231+
serializedErrors,
232+
}
233+
}
226234
case HMR_MESSAGE_SENT_TO_BROWSER.REACT_DEBUG_CHUNK: {
227235
assertByteLength(data, 2)
228236
const requestIdLength = view.getUint8(1)

packages/next/src/client/dev/hot-reloader/pages/hot-reloader-pages.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ function processMessage(message: HmrMessageSentToBrowser) {
394394
break
395395
case HMR_MESSAGE_SENT_TO_BROWSER.CACHE_INDICATOR:
396396
case HMR_MESSAGE_SENT_TO_BROWSER.REACT_DEBUG_CHUNK:
397+
case HMR_MESSAGE_SENT_TO_BROWSER.ERRORS_TO_SHOW_IN_BROWSER:
397398
// Only relevant for app router.
398399
break
399400
case HMR_MESSAGE_SENT_TO_BROWSER.MIDDLEWARE_CHANGES:

packages/next/src/client/page-bootstrap.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export function pageBootstrap(assetPrefix: string) {
124124
case HMR_MESSAGE_SENT_TO_BROWSER.REQUEST_CURRENT_ERROR_STATE:
125125
case HMR_MESSAGE_SENT_TO_BROWSER.REQUEST_PAGE_METADATA:
126126
case HMR_MESSAGE_SENT_TO_BROWSER.CACHE_INDICATOR:
127+
case HMR_MESSAGE_SENT_TO_BROWSER.ERRORS_TO_SHOW_IN_BROWSER:
127128
// Most of these action types are handled in
128129
// src/client/dev/hot-reloader/pages/hot-reloader-pages.ts and
129130
// src/client/dev/hot-reloader/app/hot-reloader-app.tsx

packages/next/src/server/app-render/app-render.tsx

Lines changed: 51 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -867,7 +867,6 @@ async function generateDynamicFlightRenderResultWithStagesInDev(
867867
consoleAsyncStorage.run(
868868
{ dim: true },
869869
spawnStaticShellValidationInDev,
870-
resolveValidation,
871870
staticChunks,
872871
runtimeChunks,
873872
dynamicChunks,
@@ -2670,7 +2669,6 @@ async function renderToStream(
26702669
// We only have a Prerender environment for projects opted into cacheComponents
26712670
cacheComponents
26722671
) {
2673-
const [resolveValidation, validationOutlet] = createValidationOutlet()
26742672
let debugChannel: DebugChannelPair | undefined
26752673
const getPayload = async (
26762674
// eslint-disable-next-line @typescript-eslint/no-shadow
@@ -2695,12 +2693,6 @@ async function renderToStream(
26952693
payload._bypassCachesInDev = createElement(WarnForBypassCachesInDev, {
26962694
route: workStore.route,
26972695
})
2698-
} else {
2699-
// Placing the validation outlet in the payload is safe
2700-
// even if we end up discarding a render and restarting,
2701-
// because we're not going to wait for the stream to complete,
2702-
// so leaving the validation unresolved is fine.
2703-
payload._validation = validationOutlet
27042696
}
27052697

27062698
return payload
@@ -2743,7 +2735,6 @@ async function renderToStream(
27432735
consoleAsyncStorage.run(
27442736
{ dim: true },
27452737
spawnStaticShellValidationInDev,
2746-
resolveValidation,
27472738
staticChunks,
27482739
runtimeChunks,
27492740
dynamicChunks,
@@ -3622,14 +3613,59 @@ function createValidationOutlet() {
36223613
return [resolveValidation!, outlet] as const
36233614
}
36243615

3616+
/**
3617+
* Logs the given messages, and sends the error instances to the browser as an
3618+
* RSC stream, where they can be deserialized and logged (or otherwise presented
3619+
* in the devtools), while leveraging React's capabilities to not only
3620+
* source-map the stack frames (via findSourceMapURL), but also create virtual
3621+
* server modules that allow users to inspect the server source code in the
3622+
* browser.
3623+
*/
3624+
async function logMessagesAndSendErrorsToBrowser(
3625+
messages: unknown[],
3626+
ctx: AppRenderContext
3627+
): Promise<void> {
3628+
const {
3629+
clientReferenceManifest,
3630+
componentMod: ComponentMod,
3631+
htmlRequestId,
3632+
renderOpts,
3633+
} = ctx
3634+
3635+
const { sendErrorsToBrowser } = renderOpts
3636+
3637+
const errors: Error[] = []
3638+
for (const message of messages) {
3639+
console.error(message)
3640+
if (message instanceof Error) {
3641+
errors.push(message)
3642+
}
3643+
}
3644+
3645+
if (errors.length > 0) {
3646+
if (!sendErrorsToBrowser) {
3647+
throw new InvariantError(
3648+
'Expected `sendErrorsToBrowser` to be defined in renderOpts.'
3649+
)
3650+
}
3651+
3652+
const errorsRscStream = ComponentMod.renderToReadableStream(
3653+
errors,
3654+
clientReferenceManifest.clientModules,
3655+
{ filterStackFrame }
3656+
)
3657+
3658+
sendErrorsToBrowser(errorsRscStream, htmlRequestId)
3659+
}
3660+
}
3661+
36253662
/**
36263663
* This function is a fork of prerenderToStream cacheComponents branch.
36273664
* While it doesn't return a stream we want it to have identical
36283665
* prerender semantics to prerenderToStream and should update it
36293666
* in conjunction with any changes to that function.
36303667
*/
36313668
async function spawnStaticShellValidationInDev(
3632-
resolveValidation: (validatingElement: ReactNode) => void,
36333669
staticServerChunks: Array<Uint8Array>,
36343670
runtimeServerChunks: Array<Uint8Array>,
36353671
dynamicServerChunks: Array<Uint8Array>,
@@ -3664,36 +3700,19 @@ async function spawnStaticShellValidationInDev(
36643700
NEXT_HMR_REFRESH_HASH_COOKIE
36653701
)?.value
36663702

3667-
const { createElement } = ComponentMod
3668-
36693703
// We don't need to continue the prerender process if we already
36703704
// detected invalid dynamic usage in the initial prerender phase.
36713705
const { invalidDynamicUsageError } = workStore
36723706
if (invalidDynamicUsageError) {
3673-
resolveValidation(
3674-
createElement(ReportValidation, {
3675-
messages: [invalidDynamicUsageError],
3676-
})
3677-
)
3678-
return
3707+
return logMessagesAndSendErrorsToBrowser([invalidDynamicUsageError], ctx)
36793708
}
36803709

36813710
if (staticInterruptReason) {
3682-
resolveValidation(
3683-
createElement(ReportValidation, {
3684-
messages: [staticInterruptReason],
3685-
})
3686-
)
3687-
return
3711+
return logMessagesAndSendErrorsToBrowser([staticInterruptReason], ctx)
36883712
}
36893713

36903714
if (runtimeInterruptReason) {
3691-
resolveValidation(
3692-
createElement(ReportValidation, {
3693-
messages: [runtimeInterruptReason],
3694-
})
3695-
)
3696-
return
3715+
return logMessagesAndSendErrorsToBrowser([runtimeInterruptReason], ctx)
36973716
}
36983717

36993718
// First we warmup SSR with the runtime chunks. This ensures that when we do
@@ -3732,10 +3751,7 @@ async function spawnStaticShellValidationInDev(
37323751
if (runtimeResult.length > 0) {
37333752
// We have something to report from the runtime validation
37343753
// We can skip the static validation
3735-
resolveValidation(
3736-
createElement(ReportValidation, { messages: runtimeResult })
3737-
)
3738-
return
3754+
return logMessagesAndSendErrorsToBrowser(runtimeResult, ctx)
37393755
}
37403756

37413757
const staticResult = await validateStagedShell(
@@ -3752,11 +3768,7 @@ async function spawnStaticShellValidationInDev(
37523768
trackDynamicHoleInStaticShell
37533769
)
37543770

3755-
// We always resolve with whatever results we got. It might be empty in which
3756-
// case there will be nothing to report once
3757-
resolveValidation(createElement(ReportValidation, { messages: staticResult }))
3758-
3759-
return
3771+
return logMessagesAndSendErrorsToBrowser(staticResult, ctx)
37603772
}
37613773

37623774
async function warmupModuleCacheForRuntimeValidationInDev(
@@ -4051,13 +4063,6 @@ async function validateStagedShell(
40514063
}
40524064
}
40534065

4054-
function ReportValidation({ messages }: { messages: Array<unknown> }): null {
4055-
for (const message of messages) {
4056-
console.error(message)
4057-
}
4058-
return null
4059-
}
4060-
40614066
type PrerenderToStreamResult = {
40624067
stream: ReadableStream<Uint8Array>
40634068
digestErrorsMap: Map<string, DigestedError>

packages/next/src/server/app-render/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ export interface RenderOptsPartial {
116116
htmlRequestId: string,
117117
requestId: string
118118
) => void
119+
sendErrorsToBrowser?: (
120+
errorsRscStream: ReadableStream<Uint8Array>,
121+
htmlRequestId: string
122+
) => void
119123
nextExport?: boolean
120124
nextConfigOutput?: 'standalone' | 'export'
121125
onInstrumentationRequestError?: ServerOnInstrumentationRequestError

packages/next/src/server/dev/hot-reloader-turbopack.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ import { recordMcpTelemetry } from '../mcp/mcp-telemetry-tracker'
126126
import { getFileLogger } from './browser-logs/file-logger'
127127
import type { ServerCacheStatus } from '../../next-devtools/dev-overlay/cache-indicator'
128128
import type { Lockfile } from '../../build/lockfile'
129+
import { streamToUint8Array } from '../stream-utils/node-web-streams-helper'
129130

130131
const wsServer = new ws.Server({ noServer: true })
131132
const isTestMode = !!(
@@ -441,6 +442,10 @@ export async function createHotReloaderTurbopack(
441442
const clientsWithoutHtmlRequestId = new Set<ws>()
442443
const clientsByHtmlRequestId = new Map<string, ws>()
443444
const cacheStatusesByHtmlRequestId = new Map<string, ServerCacheStatus>()
445+
const errorsRscStreamsByHtmlRequestId = new Map<
446+
string,
447+
ReadableStream<Uint8Array>
448+
>()
444449
const clientStates = new WeakMap<ws, ClientState>()
445450

446451
function sendToClient(client: ws, message: HmrMessageSentToBrowser) {
@@ -907,6 +912,23 @@ export async function createHotReloaderTurbopack(
907912
} else {
908913
onUpgrade(client, { isLegacyClient: true })
909914
}
915+
916+
connectReactDebugChannelForHtmlRequest(
917+
htmlRequestId,
918+
sendToClient.bind(null, client)
919+
)
920+
921+
const errorsRscStream =
922+
errorsRscStreamsByHtmlRequestId.get(htmlRequestId)
923+
if (errorsRscStream !== undefined) {
924+
streamToUint8Array(errorsRscStream).then((serializedErrors) =>
925+
sendToClient(client, {
926+
type: HMR_MESSAGE_SENT_TO_BROWSER.ERRORS_TO_SHOW_IN_BROWSER,
927+
serializedErrors,
928+
})
929+
)
930+
errorsRscStreamsByHtmlRequestId.delete(htmlRequestId)
931+
}
910932
} else {
911933
clientsWithoutHtmlRequestId.add(client)
912934
onUpgrade(client, { isLegacyClient: true })
@@ -1098,13 +1120,6 @@ export async function createHotReloaderTurbopack(
10981120
}
10991121

11001122
sendToClient(client, syncMessage)
1101-
1102-
if (htmlRequestId) {
1103-
connectReactDebugChannelForHtmlRequest(
1104-
htmlRequestId,
1105-
sendToClient.bind(null, client)
1106-
)
1107-
}
11081123
})()
11091124
})
11101125
},
@@ -1185,6 +1200,22 @@ export async function createHotReloaderTurbopack(
11851200
}
11861201
},
11871202

1203+
sendErrorsToBrowser(errorsRscStream, htmlRequestId) {
1204+
const client = clientsByHtmlRequestId.get(htmlRequestId)
1205+
if (client !== undefined) {
1206+
streamToUint8Array(errorsRscStream).then((serializedErrors) => {
1207+
sendToClient(client, {
1208+
type: HMR_MESSAGE_SENT_TO_BROWSER.ERRORS_TO_SHOW_IN_BROWSER,
1209+
serializedErrors,
1210+
})
1211+
})
1212+
} else {
1213+
// If the client is not connected, store the errors stream so that we
1214+
// can send it when the client connects.
1215+
errorsRscStreamsByHtmlRequestId.set(htmlRequestId, errorsRscStream)
1216+
}
1217+
},
1218+
11881219
setHmrServerError(_error) {
11891220
// Not implemented yet.
11901221
},

packages/next/src/server/dev/hot-reloader-types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const enum HMR_MESSAGE_SENT_TO_BROWSER {
4040

4141
// Binary messages:
4242
REACT_DEBUG_CHUNK = 0,
43+
ERRORS_TO_SHOW_IN_BROWSER = 1,
4344
}
4445

4546
export const enum HMR_MESSAGE_SENT_TO_SERVER {
@@ -157,6 +158,11 @@ export interface ReactDebugChunkMessage {
157158
chunk: Uint8Array | null
158159
}
159160

161+
export interface ErrorsToShowInBrowserMessage {
162+
type: HMR_MESSAGE_SENT_TO_BROWSER.ERRORS_TO_SHOW_IN_BROWSER
163+
serializedErrors: Uint8Array
164+
}
165+
160166
export interface RequestCurrentErrorStateMessage {
161167
type: HMR_MESSAGE_SENT_TO_BROWSER.REQUEST_CURRENT_ERROR_STATE
162168
requestId: string
@@ -189,6 +195,7 @@ export type HmrMessageSentToBrowser =
189195
| ServerErrorMessage
190196
| AppIsrManifestMessage
191197
| DevToolsConfigMessage
198+
| ErrorsToShowInBrowserMessage
192199
| ReactDebugChunkMessage
193200
| RequestCurrentErrorStateMessage
194201
| RequestPageMetadataMessage
@@ -235,6 +242,10 @@ export interface NextJsHotReloaderInterface {
235242
htmlRequestId: string,
236243
requestId: string
237244
): void
245+
sendErrorsToBrowser(
246+
errorsRscStream: ReadableStream<Uint8Array>,
247+
htmlRequestId: string
248+
): void
238249
getCompilationErrors(page: string): Promise<any[]>
239250
onHMR(
240251
req: IncomingMessage,

0 commit comments

Comments
 (0)