diff --git a/packages/next/package.json b/packages/next/package.json index 8e8c9d982e64a..14b1365b9a0dc 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -309,6 +309,7 @@ "react-refresh": "0.12.0", "recast": "0.23.11", "regenerator-runtime": "0.13.4", + "safe-stable-stringify": "2.5.0", "sass-loader": "15.0.0", "schema-utils2": "npm:schema-utils@2.7.1", "schema-utils3": "npm:schema-utils@3.0.0", diff --git a/packages/next/src/build/define-env.ts b/packages/next/src/build/define-env.ts index e010f52534721..651adc14c9626 100644 --- a/packages/next/src/build/define-env.ts +++ b/packages/next/src/build/define-env.ts @@ -321,6 +321,10 @@ export function getDefineEnv({ isDevToolPanelUIEnabled || !!config.experimental.devtoolSegmentExplorer, 'process.env.__NEXT_DEVTOOL_NEW_PANEL_UI': isDevToolPanelUIEnabled, + 'process.env.__NEXT_BROWSER_DEBUG_INFO_IN_TERMINAL': JSON.stringify( + config.experimental.browserDebugInfoInTerminal || false + ), + // The devtools need to know whether or not to show an option to clear the // bundler cache. This option may be removed later once Turbopack's // persistent cache feature is more stable. diff --git a/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx b/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx index 5bd52989077e9..bb5b3fe0618df 100644 --- a/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx +++ b/packages/next/src/client/dev/hot-reloader/app/hot-reloader-app.tsx @@ -31,6 +31,7 @@ import reportHmrLatency from '../../report-hmr-latency' import { TurbopackHmr } from '../turbopack-hot-reloader-common' import { NEXT_HMR_REFRESH_HASH_COOKIE } from '../../../components/app-router-headers' import type { GlobalErrorState } from '../../../components/app-router-instance' +import { useForwardConsoleLog } from '../../../../next-devtools/userspace/app/errors/use-forward-console-log' let mostRecentCompilationHash: any = null let __nextDevClientId = Math.round(Math.random() * 100 + Date.now()) @@ -456,8 +457,10 @@ export default function HotReload({ useErrorHandler(dispatcher.onUnhandledError, dispatcher.onUnhandledRejection) const webSocketRef = useWebsocket(assetPrefix) + useWebsocketPing(webSocketRef) const sendMessage = useSendMessage(webSocketRef) + useForwardConsoleLog(webSocketRef) const processTurbopackMessage = useTurbopack(sendMessage, (err) => performFullReload(err, sendMessage) ) @@ -533,7 +536,6 @@ export default function HotReload({ processTurbopackMessage, appIsrManifestRef, ]) - return ( diff --git a/packages/next/src/client/dev/hot-reloader/pages/websocket.ts b/packages/next/src/client/dev/hot-reloader/pages/websocket.ts index 8f25dac1a289b..1fbd0a4c1cebb 100644 --- a/packages/next/src/client/dev/hot-reloader/pages/websocket.ts +++ b/packages/next/src/client/dev/hot-reloader/pages/websocket.ts @@ -1,3 +1,7 @@ +import { + isTerminalLoggingEnabled, + logQueue, +} from '../../../../next-devtools/userspace/app/forward-logs' import { HMR_ACTIONS_SENT_TO_BROWSER, type HMR_ACTION_TYPES, @@ -28,6 +32,9 @@ export function connectHMR(options: { path: string; assetPrefix: string }) { if (source) source.close() function handleOnline() { + if (isTerminalLoggingEnabled) { + logQueue.onSocketReady(source) + } reconnections = 0 window.console.log('[HMR] connected') } diff --git a/packages/next/src/compiled/safe-stable-stringify/LICENSE b/packages/next/src/compiled/safe-stable-stringify/LICENSE new file mode 100644 index 0000000000000..99c65e2c7d527 --- /dev/null +++ b/packages/next/src/compiled/safe-stable-stringify/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Ruben Bridgewater + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/next/src/compiled/safe-stable-stringify/index.js b/packages/next/src/compiled/safe-stable-stringify/index.js new file mode 100644 index 0000000000000..1bdd42c4ca4a1 --- /dev/null +++ b/packages/next/src/compiled/safe-stable-stringify/index.js @@ -0,0 +1 @@ +(function(){"use strict";var e={879:function(e,t){const{hasOwnProperty:n}=Object.prototype;const r=configure();r.configure=configure;r.stringify=r;r.default=r;t.stringify=r;t.configure=configure;e.exports=r;const i=/[\u0000-\u001f\u0022\u005c\ud800-\udfff]/;function strEscape(e){if(e.length<5e3&&!i.test(e)){return`"${e}"`}return JSON.stringify(e)}function sort(e,t){if(e.length>200||t){return e.sort(t)}for(let t=1;tn){e[r]=e[r-1];r--}e[r]=n}return e}const f=Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Object.getPrototypeOf(new Int8Array)),Symbol.toStringTag).get;function isTypedArrayWithEntries(e){return f.call(e)!==undefined&&e.length!==0}function stringifyTypedArray(e,t,n){if(e.length= 1`)}}return r===undefined?Infinity:r}function getItemCount(e){if(e===1){return"1 item"}return`${e} items`}function getUniqueReplacerSet(e){const t=new Set;for(const n of e){if(typeof n==="string"||typeof n==="number"){t.add(String(n))}}return t}function getStrictOption(e){if(n.call(e,"strict")){const t=e.strict;if(typeof t!=="boolean"){throw new TypeError('The "strict" argument must be of type boolean')}if(t){return e=>{let t=`Object can not safely be stringified. Received type ${typeof e}`;if(typeof e!=="function")t+=` (${e.toString()})`;throw new Error(t)}}}}function configure(e){e={...e};const t=getStrictOption(e);if(t){if(e.bigint===undefined){e.bigint=false}if(!("circularValue"in e)){e.circularValue=Error}}const n=getCircularValueOption(e);const r=getBooleanOption(e,"bigint");const i=getDeterministicOption(e);const f=typeof i==="function"?i:undefined;const u=getPositiveIntegerOption(e,"maximumDepth");const o=getPositiveIntegerOption(e,"maximumBreadth");function stringifyFnReplacer(e,s,l,c,a,g){let p=s[e];if(typeof p==="object"&&p!==null&&typeof p.toJSON==="function"){p=p.toJSON(e)}p=c.call(s,e,p);switch(typeof p){case"string":return strEscape(p);case"object":{if(p===null){return"null"}if(l.indexOf(p)!==-1){return n}let e="";let t=",";const r=g;if(Array.isArray(p)){if(p.length===0){return"[]"}if(uo){const n=p.length-o-1;e+=`${t}"... ${getItemCount(n)} not stringified"`}if(a!==""){e+=`\n${r}`}l.pop();return`[${e}]`}let s=Object.keys(p);const y=s.length;if(y===0){return"{}"}if(uo){const n=y-o;e+=`${h}"...":${d}"${getItemCount(n)} not stringified"`;h=t}if(a!==""&&h.length>1){e=`\n${g}${e}\n${r}`}l.pop();return`{${e}}`}case"number":return isFinite(p)?String(p):t?t(p):"null";case"boolean":return p===true?"true":"false";case"undefined":return undefined;case"bigint":if(r){return String(p)}default:return t?t(p):undefined}}function stringifyArrayReplacer(e,i,f,s,l,c){if(typeof i==="object"&&i!==null&&typeof i.toJSON==="function"){i=i.toJSON(e)}switch(typeof i){case"string":return strEscape(i);case"object":{if(i===null){return"null"}if(f.indexOf(i)!==-1){return n}const e=c;let t="";let r=",";if(Array.isArray(i)){if(i.length===0){return"[]"}if(uo){const e=i.length-o-1;t+=`${r}"... ${getItemCount(e)} not stringified"`}if(l!==""){t+=`\n${e}`}f.pop();return`[${t}]`}f.push(i);let a="";if(l!==""){c+=l;r=`,\n${c}`;a=" "}let g="";for(const e of s){const n=stringifyArrayReplacer(e,i[e],f,s,l,c);if(n!==undefined){t+=`${g}${strEscape(e)}:${a}${n}`;g=r}}if(l!==""&&g.length>1){t=`\n${c}${t}\n${e}`}f.pop();return`{${t}}`}case"number":return isFinite(i)?String(i):t?t(i):"null";case"boolean":return i===true?"true":"false";case"undefined":return undefined;case"bigint":if(r){return String(i)}default:return t?t(i):undefined}}function stringifyIndent(e,s,l,c,a){switch(typeof s){case"string":return strEscape(s);case"object":{if(s===null){return"null"}if(typeof s.toJSON==="function"){s=s.toJSON(e);if(typeof s!=="object"){return stringifyIndent(e,s,l,c,a)}if(s===null){return"null"}}if(l.indexOf(s)!==-1){return n}const t=a;if(Array.isArray(s)){if(s.length===0){return"[]"}if(uo){const t=s.length-o-1;e+=`${n}"... ${getItemCount(t)} not stringified"`}e+=`\n${t}`;l.pop();return`[${e}]`}let r=Object.keys(s);const g=r.length;if(g===0){return"{}"}if(uo){const e=g-o;y+=`${d}"...": "${getItemCount(e)} not stringified"`;d=p}if(d!==""){y=`\n${a}${y}\n${t}`}l.pop();return`{${y}}`}case"number":return isFinite(s)?String(s):t?t(s):"null";case"boolean":return s===true?"true":"false";case"undefined":return undefined;case"bigint":if(r){return String(s)}default:return t?t(s):undefined}}function stringifySimple(e,s,l){switch(typeof s){case"string":return strEscape(s);case"object":{if(s===null){return"null"}if(typeof s.toJSON==="function"){s=s.toJSON(e);if(typeof s!=="object"){return stringifySimple(e,s,l)}if(s===null){return"null"}}if(l.indexOf(s)!==-1){return n}let t="";const r=s.length!==undefined;if(r&&Array.isArray(s)){if(s.length===0){return"[]"}if(uo){const e=s.length-o-1;t+=`,"... ${getItemCount(e)} not stringified"`}l.pop();return`[${t}]`}let c=Object.keys(s);const a=c.length;if(a===0){return"{}"}if(uo){const e=a-o;t+=`${g}"...":"${getItemCount(e)} not stringified"`}l.pop();return`{${t}}`}case"number":return isFinite(s)?String(s):t?t(s):"null";case"boolean":return s===true?"true":"false";case"undefined":return undefined;case"bigint":if(r){return String(s)}default:return t?t(s):undefined}}function stringify(e,t,n){if(arguments.length>1){let r="";if(typeof n==="number"){r=" ".repeat(Math.min(n,10))}else if(typeof n==="string"){r=n.slice(0,10)}if(t!=null){if(typeof t==="function"){return stringifyFnReplacer("",{"":e},[],t,r,"")}if(Array.isArray(t)){return stringifyArrayReplacer("",e,[],getUniqueReplacerSet(t),r,"")}}if(r.length!==0){return stringifyIndent("",e,[],r,"")}}return stringifySimple("",e,[])}return stringify}}};var t={};function __nccwpck_require__(n){var r=t[n];if(r!==undefined){return r.exports}var i=t[n]={exports:{}};var f=true;try{e[n](i,i.exports,__nccwpck_require__);f=false}finally{if(f)delete t[n]}return i.exports}if(typeof __nccwpck_require__!=="undefined")__nccwpck_require__.ab=__dirname+"/";var n=__nccwpck_require__(879);module.exports=n})(); \ No newline at end of file diff --git a/packages/next/src/compiled/safe-stable-stringify/package.json b/packages/next/src/compiled/safe-stable-stringify/package.json new file mode 100644 index 0000000000000..ea5b015f6610c --- /dev/null +++ b/packages/next/src/compiled/safe-stable-stringify/package.json @@ -0,0 +1 @@ +{"name":"safe-stable-stringify","main":"index.js","author":"Ruben Bridgewater","license":"MIT"} diff --git a/packages/next/src/next-devtools/shared/forward-logs-shared.ts b/packages/next/src/next-devtools/shared/forward-logs-shared.ts new file mode 100644 index 0000000000000..c4acf9b0601fd --- /dev/null +++ b/packages/next/src/next-devtools/shared/forward-logs-shared.ts @@ -0,0 +1,114 @@ +export type LogMethod = + | 'log' + | 'info' + | 'debug' + | 'table' + | 'error' + | 'assert' + | 'dir' + | 'dirxml' + | 'group' + | 'groupCollapsed' + | 'groupEnd' + | 'trace' + | 'warn' + +export type ConsoleEntry = { + kind: 'console' + method: LogMethod + consoleMethodStack: string | null + args: Array< + | { + kind: 'arg' + data: T + } + | { + kind: 'formatted-error-arg' + prefix: string + stack: string + } + > +} + +export type ConsoleErrorEntry = { + kind: 'any-logged-error' + method: 'error' + consoleErrorStack: string + args: Array< + | { + kind: 'arg' + data: T + isRejectionMessage?: boolean + } + | { + kind: 'formatted-error-arg' + prefix: string + stack: string | null + } + > +} + +export type FormattedErrorEntry = { + kind: 'formatted-error' + prefix: string + stack: string + method: 'error' +} + +export type ClientLogEntry = + | ConsoleEntry + | ConsoleErrorEntry + | FormattedErrorEntry +export type ServerLogEntry = + | ConsoleEntry + | ConsoleErrorEntry + | FormattedErrorEntry + +export const UNDEFINED_MARKER = '__next_tagged_undefined' + +// Based on https://github.com/facebook/react/blob/28dc0776be2e1370fe217549d32aee2519f0cf05/packages/react-server/src/ReactFlightServer.js#L248 +export function patchConsoleMethod( + methodName: T, + wrapper: ( + methodName: T, + ...args: Console[T] extends (...args: infer P) => any ? P : never[] + ) => void +): () => void { + const descriptor = Object.getOwnPropertyDescriptor(console, methodName) + if ( + descriptor && + (descriptor.configurable || descriptor.writable) && + typeof descriptor.value === 'function' + ) { + const originalMethod = descriptor.value as Console[T] extends ( + ...args: any[] + ) => any + ? Console[T] + : never + const originalName = Object.getOwnPropertyDescriptor(originalMethod, 'name') + const wrapperMethod = function ( + this: typeof console, + ...args: Console[T] extends (...args: infer P) => any ? P : never[] + ) { + wrapper(methodName, ...args) + + originalMethod.apply(this, args) + } + if (originalName) { + Object.defineProperty(wrapperMethod, 'name', originalName) + } + Object.defineProperty(console, methodName, { + value: wrapperMethod, + }) + + return () => { + Object.defineProperty(console, methodName, { + value: originalMethod, + writable: descriptor.writable, + configurable: descriptor.configurable, + }) + } + } + + return () => {} +} diff --git a/packages/next/src/next-devtools/userspace/app/app-dev-overlay-setup.ts b/packages/next/src/next-devtools/userspace/app/app-dev-overlay-setup.ts index a654770cb342c..bad6ec6bb35b4 100644 --- a/packages/next/src/next-devtools/userspace/app/app-dev-overlay-setup.ts +++ b/packages/next/src/next-devtools/userspace/app/app-dev-overlay-setup.ts @@ -1,5 +1,13 @@ import { patchConsoleError } from './errors/intercept-console-error' import { handleGlobalErrors } from './errors/use-error-handler' +import { + initializeDebugLogForwarding, + isTerminalLoggingEnabled, +} from './forward-logs' handleGlobalErrors() patchConsoleError() + +if (isTerminalLoggingEnabled) { + initializeDebugLogForwarding('app') +} diff --git a/packages/next/src/next-devtools/userspace/app/errors/intercept-console-error.ts b/packages/next/src/next-devtools/userspace/app/errors/intercept-console-error.ts index f7bcd95e09a4a..b4177c56b5549 100644 --- a/packages/next/src/next-devtools/userspace/app/errors/intercept-console-error.ts +++ b/packages/next/src/next-devtools/userspace/app/errors/intercept-console-error.ts @@ -2,6 +2,7 @@ import isError from '../../../../lib/is-error' import { isNextRouterError } from '../../../../client/components/is-next-router-error' import { handleConsoleError } from './use-error-handler' import { parseConsoleArgs } from '../../../../client/lib/console' +import { forwardErrorLog, isTerminalLoggingEnabled } from '../forward-logs' export const originConsoleError = globalThis.console.error @@ -36,6 +37,9 @@ export function patchConsoleError() { args ) } + if (isTerminalLoggingEnabled) { + forwardErrorLog(args) + } originConsoleError.apply(window.console, args) } diff --git a/packages/next/src/next-devtools/userspace/app/errors/use-error-handler.ts b/packages/next/src/next-devtools/userspace/app/errors/use-error-handler.ts index 72e39d51c1255..aab7e6bfc4fec 100644 --- a/packages/next/src/next-devtools/userspace/app/errors/use-error-handler.ts +++ b/packages/next/src/next-devtools/userspace/app/errors/use-error-handler.ts @@ -7,6 +7,11 @@ import { import isError from '../../../../lib/is-error' import { createConsoleError } from '../../../shared/console-error' import { coerceError, setOwnerStackIfAvailable } from './stitched-error' +import { + forwardUnhandledError, + isTerminalLoggingEnabled, + logUnhandledRejection, +} from '../forward-logs' const queueMicroTask = globalThis.queueMicrotask || ((cb: () => void) => Promise.resolve().then(cb)) @@ -94,8 +99,10 @@ function onUnhandledError(event: WindowEventMap['error']): void | boolean { if (thrownValue) { const error = coerceError(thrownValue) setOwnerStackIfAvailable(error) - handleClientError(error) + if (isTerminalLoggingEnabled) { + forwardUnhandledError(error) + } } } @@ -113,6 +120,10 @@ function onUnhandledRejection(ev: WindowEventMap['unhandledrejection']): void { for (const handler of rejectionHandlers) { handler(error) } + + if (isTerminalLoggingEnabled) { + logUnhandledRejection(reason) + } } export function handleGlobalErrors() { diff --git a/packages/next/src/next-devtools/userspace/app/errors/use-forward-console-log.ts b/packages/next/src/next-devtools/userspace/app/errors/use-forward-console-log.ts new file mode 100644 index 0000000000000..29e44f8e6a703 --- /dev/null +++ b/packages/next/src/next-devtools/userspace/app/errors/use-forward-console-log.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react' +import { isTerminalLoggingEnabled, logQueue } from '../forward-logs' +import type { useWebsocket } from '../../../../client/dev/hot-reloader/app/use-websocket' + +export const useForwardConsoleLog = ( + socketRef: ReturnType +) => { + useEffect(() => { + if (!isTerminalLoggingEnabled) { + return + } + const socket = socketRef.current + if (!socket) { + return + } + + const onOpen = () => { + logQueue.onSocketReady(socket) + } + socket.addEventListener('open', onOpen) + + return () => { + socket.removeEventListener('open', onOpen) + } + }, [socketRef]) +} diff --git a/packages/next/src/next-devtools/userspace/app/forward-logs.test.ts b/packages/next/src/next-devtools/userspace/app/forward-logs.test.ts new file mode 100644 index 0000000000000..7e5d53a3c490e --- /dev/null +++ b/packages/next/src/next-devtools/userspace/app/forward-logs.test.ts @@ -0,0 +1,114 @@ +import { UNDEFINED_MARKER } from '../../shared/forward-logs-shared' +import { + preLogSerializationClone, + PROMISE_MARKER, + UNAVAILABLE_MARKER, + logStringify, +} from './forward-logs' + +const safeStringify = (data: unknown) => + logStringify(preLogSerializationClone(data)) + +describe('forward-logs serialization', () => { + describe('safeClone', () => { + it('should handle primitive values and null', () => { + expect(preLogSerializationClone(42)).toBe(42) + expect(preLogSerializationClone('hello')).toBe('hello') + expect(preLogSerializationClone(true)).toBe(true) + expect(preLogSerializationClone(null)).toBe(null) + expect(preLogSerializationClone(undefined)).toBe(UNDEFINED_MARKER) + }) + + it('should handle circular references', () => { + const obj: any = { a: 1 } + obj.self = obj + const cloned = preLogSerializationClone(obj) + expect(cloned.a).toBe(1) + expect(cloned.self).toBe(cloned) + }) + + it('should handle promises', () => { + const promise = Promise.resolve(42) + expect(preLogSerializationClone(promise)).toBe(PROMISE_MARKER) + }) + + it('should handle arrays', () => { + const arr = [1, 'test', undefined, null] + const cloned = preLogSerializationClone(arr) + expect(cloned).toEqual([1, 'test', UNDEFINED_MARKER, null]) + }) + + it('should handle plain objects', () => { + const obj = { a: 1, b: undefined, c: 'test' } + const cloned = preLogSerializationClone(obj) + expect(cloned).toEqual({ a: 1, b: UNDEFINED_MARKER, c: 'test' }) + }) + + it('should handle objects with getters that throw', () => { + const obj = { + normalProp: 'works', + get throwingGetter() { + throw new Error('Getter throws') + }, + } + + const cloned = preLogSerializationClone(obj) + expect(cloned.normalProp).toBe('works') + expect(cloned.throwingGetter).toBe(UNAVAILABLE_MARKER) + }) + + it('should handle non-plain objects as toString', () => { + const date = new Date('2023-01-01') + const regex = /test/gi + const error = new Error('Test error') + + expect(preLogSerializationClone(date)).toBe('[object Date]') + expect(preLogSerializationClone(regex)).toBe('[object RegExp]') + expect(preLogSerializationClone(error)).toBe('[object Error]') + }) + + it('should handle array items that throw', () => { + const throwingProxy = new Proxy( + {}, + { + get() { + throw new Error('Proxy throws') + }, + } + ) + + const arr = [1, throwingProxy, 'normal'] + const cloned = preLogSerializationClone(arr) + + expect(cloned).toEqual([1, UNAVAILABLE_MARKER, 'normal']) + }) + }) + + describe('logStringify', () => { + it('should stringify safe cloned data', () => { + expect(safeStringify(42)).toBe('42') + expect(safeStringify('hello')).toBe('"hello"') + expect(safeStringify(null)).toBe('null') + expect(safeStringify(undefined)).toBe(`"${UNDEFINED_MARKER}"`) + }) + + it('should handle objects with circular references', () => { + const obj: any = { a: 1 } + obj.self = obj + const result = safeStringify(obj) + expect(typeof result).toBe('string') + expect(result).toContain('"a":1') + }) + + it('should return UNAVAILABLE_MARKER on stringify failure', () => { + const problematicData = { + toJSON() { + throw new Error('toJSON throws') + }, + } + + const result = safeStringify(problematicData) + expect(result).toBe(`"${UNAVAILABLE_MARKER}"`) + }) + }) +}) diff --git a/packages/next/src/next-devtools/userspace/app/forward-logs.ts b/packages/next/src/next-devtools/userspace/app/forward-logs.ts new file mode 100644 index 0000000000000..bf55f678524dc --- /dev/null +++ b/packages/next/src/next-devtools/userspace/app/forward-logs.ts @@ -0,0 +1,462 @@ +import { configure } from 'next/dist/compiled/safe-stable-stringify' +import { + getOwnerStack, + setOwnerStackIfAvailable, +} from './errors/stitched-error' +import { getErrorSource } from '../../../shared/lib/error-source' +import { + getTerminalLoggingConfig, + getIsTerminalLoggingEnabled, +} from './terminal-logging-config' +import { + type ConsoleEntry, + type ConsoleErrorEntry, + type FormattedErrorEntry, + type ClientLogEntry, + type LogMethod, + patchConsoleMethod, + UNDEFINED_MARKER, +} from '../../shared/forward-logs-shared' + +const terminalLoggingConfig = getTerminalLoggingConfig() +export const PROMISE_MARKER = 'Promise {}' +export const UNAVAILABLE_MARKER = '[Unable to view]' + +const maximumDepth = + typeof terminalLoggingConfig === 'object' && terminalLoggingConfig.depthLimit + ? terminalLoggingConfig.depthLimit + : 5 +const maximumBreadth = + typeof terminalLoggingConfig === 'object' && terminalLoggingConfig.edgeLimit + ? terminalLoggingConfig.edgeLimit + : 100 + +const stringify = configure({ + maximumDepth, + maximumBreadth, +}) + +export const isTerminalLoggingEnabled = getIsTerminalLoggingEnabled() + +const methods: Array = [ + 'log', + 'info', + 'warn', + 'debug', + 'table', + 'assert', + 'dir', + 'dirxml', + 'group', + 'groupCollapsed', + 'groupEnd', + 'trace', +] +/** + * allows us to: + * - revive the undefined log in the server as it would look in the browser + * - not read/attempt to serialize promises (next will console error if you do that, and will cause this program to infinitely recurse) + * - if we read a proxy that throws (no way to detect if something is a proxy), explain to the user we can't read this data + */ +export function preLogSerializationClone( + value: T, + seen = new WeakMap() +): any { + if (value === undefined) return UNDEFINED_MARKER + if (value === null || typeof value !== 'object') return value + if (seen.has(value as object)) return seen.get(value as object) + + try { + Object.keys(value as object) + } catch { + return UNAVAILABLE_MARKER + } + + try { + if (typeof (value as any).then === 'function') return PROMISE_MARKER + } catch { + return UNAVAILABLE_MARKER + } + + if (Array.isArray(value)) { + const out: any[] = [] + seen.set(value, out) + for (const item of value) { + try { + out.push(preLogSerializationClone(item, seen)) + } catch { + out.push(UNAVAILABLE_MARKER) + } + } + return out + } + + const proto = Object.getPrototypeOf(value) + if (proto === Object.prototype || proto === null) { + const out: Record = {} + seen.set(value as object, out) + for (const key of Object.keys(value as object)) { + try { + out[key] = preLogSerializationClone((value as any)[key], seen) + } catch { + out[key] = UNAVAILABLE_MARKER + } + } + return out + } + + return Object.prototype.toString.call(value) +} + +// only safe if passed safeClone data +export const logStringify = (data: unknown): string => { + try { + const result = stringify(data) + return result ?? `"${UNAVAILABLE_MARKER}"` + } catch { + return `"${UNAVAILABLE_MARKER}"` + } +} + +const afterThisFrame = (cb: () => void) => { + let timeout: ReturnType | undefined + + const rafId = requestAnimationFrame(() => { + timeout = setTimeout(() => { + cb() + }) + }) + + return () => { + cancelAnimationFrame(rafId) + clearTimeout(timeout) + } +} + +let isPatched = false + +const serializeEntries = (entries: Array) => + entries.map((clientEntry) => { + switch (clientEntry.kind) { + case 'any-logged-error': + case 'console': { + return { + ...clientEntry, + args: clientEntry.args.map(stringifyUserArg), + } + } + case 'formatted-error': { + return clientEntry + } + default: { + return null! + } + } + }) + +export const logQueue: { + entries: Array + onSocketReady: (socket: WebSocket) => void + flushScheduled: boolean + socket: WebSocket | null + cancelFlush: (() => void) | null + sourceType?: 'server' | 'edge-server' + router: 'app' | 'pages' | null + scheduleLogSend: (entry: ClientLogEntry) => void +} = { + entries: [], + flushScheduled: false, + cancelFlush: null, + socket: null, + sourceType: undefined, + router: null, + scheduleLogSend: (entry: ClientLogEntry) => { + logQueue.entries.push(entry) + if (logQueue.flushScheduled) { + return + } + // safe to deref and use in setTimeout closure since we cancel on new socket + const socket = logQueue.socket + if (!socket) { + return + } + + // we probably dont need this + logQueue.flushScheduled = true + + // non blocking log flush, runs at most once per frame + logQueue.cancelFlush = afterThisFrame(() => { + logQueue.flushScheduled = false + + // just incase + try { + const payload = JSON.stringify({ + event: 'browser-logs', + entries: serializeEntries(logQueue.entries), + router: logQueue.router, + // needed for source mapping, we just assign the sourceType from the last error for the whole batch + sourceType: logQueue.sourceType, + }) + + socket.send(payload) + logQueue.entries = [] + logQueue.sourceType = undefined + } catch { + // error (make sure u don't infinite loop) + /* noop */ + } + }) + }, + onSocketReady: (socket: WebSocket) => { + if (socket.readyState !== WebSocket.OPEN) { + // invariant + return + } + + // incase an existing timeout was going to run with a stale socket + logQueue.cancelFlush?.() + logQueue.socket = socket + try { + const payload = JSON.stringify({ + event: 'browser-logs', + entries: serializeEntries(logQueue.entries), + router: logQueue.router, + sourceType: logQueue.sourceType, + }) + + socket.send(payload) + logQueue.entries = [] + logQueue.sourceType = undefined + } catch { + /** noop just incase */ + } + }, +} + +const stringifyUserArg = ( + arg: + | { + kind: 'arg' + data: unknown + } + | { + kind: 'formatted-error-arg' + } +) => { + if (arg.kind !== 'arg') { + return arg + } + return { + ...arg, + data: logStringify(arg.data), + } +} + +const createErrorArg = (error: Error) => { + const stack = stackWithOwners(error) + return { + kind: 'formatted-error-arg' as const, + prefix: error.message ? `${error.name}: ${error.message}` : `${error.name}`, + stack, + } +} + +const createLogEntry = (level: LogMethod, args: any[]) => { + // do not abstract this, it implicitly relies on which functions call it. forcing the inlined implementation makes you think about callers + // error capture stack trace maybe + const stack = stackWithOwners(new Error()) + const stackLines = stack?.split('\n') + const cleanStack = stackLines?.slice(3).join('\n') // this is probably ignored anyways + const entry: ConsoleEntry = { + kind: 'console', + consoleMethodStack: cleanStack ?? null, // depending on browser we might not have stack + method: level, + args: args.map((arg) => { + if (arg instanceof Error) { + return createErrorArg(arg) + } + return { + kind: 'arg', + data: preLogSerializationClone(arg), + } + }), + } + + logQueue.scheduleLogSend(entry) +} + +export const forwardErrorLog = (args: any[]) => { + const errorObjects = args.filter((arg) => arg instanceof Error) + const first = errorObjects.at(0) + if (first) { + const source = getErrorSource(first) + if (source) { + logQueue.sourceType = source + } + } + /** + * browser shows stack regardless of type of data passed to console.error, so we should do the same + * + * do not abstract this, it implicitly relies on which functions call it. forcing the inlined implementation makes you think about callers + */ + const stack = stackWithOwners(new Error()) + const stackLines = stack?.split('\n') + const cleanStack = stackLines?.slice(3).join('\n') + + const entry: ConsoleErrorEntry = { + kind: 'any-logged-error', + method: 'error', + consoleErrorStack: cleanStack ?? '', + args: args.map((arg) => { + if (arg instanceof Error) { + return createErrorArg(arg) + } + return { + kind: 'arg', + data: preLogSerializationClone(arg), + } + }), + } + + logQueue.scheduleLogSend(entry) +} + +const createUncaughtErrorEntry = ( + errorName: string, + errorMessage: string, + fullStack: string +) => { + const entry: FormattedErrorEntry = { + kind: 'formatted-error', + prefix: `Uncaught ${errorName}: ${errorMessage}`, + stack: fullStack, + method: 'error', + } + + logQueue.scheduleLogSend(entry) +} + +const stackWithOwners = (error: Error) => { + let ownerStack = '' + setOwnerStackIfAvailable(error) + ownerStack = getOwnerStack(error) || '' + const stack = (error.stack || '') + ownerStack + return stack +} + +export function logUnhandledRejection(reason: unknown) { + if (reason instanceof Error) { + createUnhandledRejectionErrorEntry(reason, stackWithOwners(reason)) + return + } + createUnhandledRejectionNonErrorEntry(reason) +} + +const createUnhandledRejectionErrorEntry = ( + error: Error, + fullStack: string +) => { + const source = getErrorSource(error) + if (source) { + logQueue.sourceType = source + } + + const entry: ClientLogEntry = { + kind: 'formatted-error', + prefix: `⨯ unhandledRejection: ${error.name}: ${error.message}`, + stack: fullStack, + method: 'error', + } + + logQueue.scheduleLogSend(entry) +} + +const createUnhandledRejectionNonErrorEntry = (reason: unknown) => { + const entry: ClientLogEntry = { + kind: 'any-logged-error', + // we can't access the stack since the event is dispatched async and creating an inline error would be meaningless + consoleErrorStack: '', + method: 'error', + args: [ + { + kind: 'arg', + data: `⨯ unhandledRejection:`, + isRejectionMessage: true, + }, + { + kind: 'arg', + data: preLogSerializationClone(reason), + }, + ], + } + + logQueue.scheduleLogSend(entry) +} + +const isHMR = (args: any[]) => { + const firstArg = args[0] + if (typeof firstArg !== 'string') { + return false + } + if (firstArg.startsWith('[Fast Refresh]')) { + return true + } + + if (firstArg.startsWith('[HMR]')) { + return true + } + + return false +} + +const isIgnoredLog = (args: any[]) => { + if (args.length < 3) { + return false + } + + const [format, styles, label] = args + + if ( + typeof format !== 'string' || + typeof styles !== 'string' || + typeof label !== 'string' + ) { + return false + } + + // kinda hacky, we should define a common format for these strings so we can safely ignore + return format.startsWith('%c%s%c') && styles.includes('background:') +} + +export function forwardUnhandledError(error: Error) { + createUncaughtErrorEntry(error.name, error.message, stackWithOwners(error)) +} + +// TODO: this router check is brittle, we need to update based on the current router the user is using +export const initializeDebugLogForwarding = (router: 'app' | 'pages'): void => { + // probably don't need this + if (isPatched) { + return + } + // TODO(rob): why does this break rendering on server, important to know incase the same bug appears in browser + if (typeof window === 'undefined') { + return + } + + // better to be safe than sorry + try { + methods.forEach((method) => + patchConsoleMethod(method, (_, ...args) => { + if (isHMR(args)) { + return + } + if (isIgnoredLog(args)) { + return + } + createLogEntry(method, args) + }) + ) + } catch {} + logQueue.router = router + isPatched = true +} diff --git a/packages/next/src/next-devtools/userspace/app/terminal-logging-config.ts b/packages/next/src/next-devtools/userspace/app/terminal-logging-config.ts new file mode 100644 index 0000000000000..52d48906e9443 --- /dev/null +++ b/packages/next/src/next-devtools/userspace/app/terminal-logging-config.ts @@ -0,0 +1,21 @@ +export function getTerminalLoggingConfig(): + | false + | boolean + | { + depthLimit?: number + edgeLimit?: number + showSourceLocation?: boolean + } { + try { + return JSON.parse( + process.env.__NEXT_BROWSER_DEBUG_INFO_IN_TERMINAL || 'false' + ) + } catch { + return false + } +} + +export function getIsTerminalLoggingEnabled(): boolean { + const config = getTerminalLoggingConfig() + return Boolean(config) +} diff --git a/packages/next/src/next-devtools/userspace/pages/pages-dev-overlay-setup.tsx b/packages/next/src/next-devtools/userspace/pages/pages-dev-overlay-setup.tsx index d2b98eccbf7a7..74311766bd01a 100644 --- a/packages/next/src/next-devtools/userspace/pages/pages-dev-overlay-setup.tsx +++ b/packages/next/src/next-devtools/userspace/pages/pages-dev-overlay-setup.tsx @@ -10,6 +10,13 @@ import { getComponentStack, getOwnerStack } from '../app/errors/stitched-error' import { isRecoverableError } from '../../../client/react-client-callbacks/on-recoverable-error' import { getSquashedHydrationErrorDetails } from './hydration-error-state' import { PagesDevOverlayErrorBoundary } from './pages-dev-overlay-error-boundary' +import { + initializeDebugLogForwarding, + forwardUnhandledError, + logUnhandledRejection, + forwardErrorLog, + isTerminalLoggingEnabled, +} from '../app/forward-logs' const usePagesDevOverlayBridge = () => { React.useInsertionEffect(() => { @@ -76,12 +83,19 @@ function nextJsHandleConsoleError(...args: any[]) { storeHydrationErrorStateFromConsoleArgs(...args) // TODO: Surfaces non-errors logged via `console.error`. handleError(maybeError) + if (isTerminalLoggingEnabled) { + forwardErrorLog(args) + } origConsoleError.apply(window.console, args) } function onUnhandledError(event: ErrorEvent) { const error = event?.error handleError(error) + + if (error && isTerminalLoggingEnabled) { + forwardUnhandledError(error as Error) + } } function onUnhandledRejection(ev: PromiseRejectionEvent) { @@ -96,6 +110,9 @@ function onUnhandledRejection(ev: PromiseRejectionEvent) { } dispatcher.onUnhandledRejection(reason) + if (isTerminalLoggingEnabled) { + logUnhandledRejection(reason) + } } export function register() { @@ -108,6 +125,9 @@ export function register() { Error.stackTraceLimit = 50 } catch {} + if (isTerminalLoggingEnabled) { + initializeDebugLogForwarding('pages') + } window.addEventListener('error', onUnhandledError) window.addEventListener('unhandledrejection', onUnhandledRejection) window.console.error = nextJsHandleConsoleError diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 12cb82fad11af..ed6d8748585f6 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -502,6 +502,16 @@ export const configSchema: zod.ZodType = z.lazy(() => globalNotFound: z.boolean().optional(), devtoolSegmentExplorer: z.boolean().optional(), devtoolNewPanelUI: z.boolean().optional(), + browserDebugInfoInTerminal: z + .union([ + z.boolean(), + z.object({ + depthLimit: z.number().int().positive().optional(), + edgeLimit: z.number().int().positive().optional(), + showSourceLocation: z.boolean().optional(), + }), + ]) + .optional(), }) .optional(), exportPathMap: z diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 97d4e4cf2b699..556379bec05ee 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -745,6 +745,29 @@ export interface ExperimentalConfig { * Enable new panel UI for the Next.js DevTools. */ devtoolNewPanelUI?: boolean + + /** + * Enable debug information to be forwarded from browser to dev server stdout/stderr + */ + browserDebugInfoInTerminal?: + | boolean + | { + /** + * Option to limit stringification at a specific nesting depth when logging circular objects. + * @default 5 + */ + depthLimit?: number + + /** + * Maximum number of properties/elements to stringify when logging objects/arrays with circular references. + * @default 100 + */ + edgeLimit?: number + /** + * Whether to include source location information in debug output when available + */ + showSourceLocation?: boolean + } } export type ExportPathMap = { @@ -1457,6 +1480,7 @@ export const defaultConfig = { globalNotFound: false, devtoolNewPanelUI: process.env.__NEXT_DEVTOOL_NEW_PANEL_UI === 'true', devtoolSegmentExplorer: process.env.__NEXT_DEVTOOL_NEW_PANEL_UI === 'true', + browserDebugInfoInTerminal: false, }, htmlLimitedBots: undefined, bundlePagesRouterDependencies: false, diff --git a/packages/next/src/server/dev/browser-logs/receive-logs.test.ts b/packages/next/src/server/dev/browser-logs/receive-logs.test.ts new file mode 100644 index 0000000000000..0c94e5db6346f --- /dev/null +++ b/packages/next/src/server/dev/browser-logs/receive-logs.test.ts @@ -0,0 +1,74 @@ +import { stripFormatSpecifiers } from './receive-logs' + +describe('stripFormatSpecifiers', () => { + it('should only process when first arg is string containing %', () => { + expect(stripFormatSpecifiers([])).toEqual([]) + expect(stripFormatSpecifiers([123])).toEqual([123]) + expect(stripFormatSpecifiers(['no percent'])).toEqual(['no percent']) + }) + + it('should replace format specifiers with their arguments', () => { + expect(stripFormatSpecifiers(['%s', 'string'])).toEqual(['string']) + expect(stripFormatSpecifiers(['Hello %s', 'world'])).toEqual([ + 'Hello world', + ]) + + expect(stripFormatSpecifiers(['%d', 42])).toEqual(['42']) + expect(stripFormatSpecifiers(['%i', 123])).toEqual(['123']) + expect(stripFormatSpecifiers(['%f', 3.14])).toEqual(['3.14']) + + expect(stripFormatSpecifiers(['%o', { a: 1 }])).toEqual(['{ a: 1 }']) + expect(stripFormatSpecifiers(['%O', { a: 1 }])).toEqual(['{ a: 1 }']) + expect(stripFormatSpecifiers(['%j', { a: 1 }])).toEqual(['{ a: 1 }']) + }) + + it('should strip CSS styling from %c format specifier', () => { + expect(stripFormatSpecifiers(['%c', 'css'])).toEqual(['']) + expect(stripFormatSpecifiers(['%cStyled text', 'color: red'])).toEqual([ + 'Styled text', + ]) + expect( + stripFormatSpecifiers(['%cError: %s', 'color: red', 'Something failed']) + ).toEqual(['Error: Something failed']) + + expect( + stripFormatSpecifiers(['%cRed %cBlue', 'color: red', 'color: blue']) + ).toEqual(['Red Blue']) + + expect( + stripFormatSpecifiers([ + '%cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools', + 'font-weight:bold', + ]) + ).toEqual([ + 'Download the React DevTools for a better development experience: https://react.dev/link/react-devtools', + ]) + + expect( + stripFormatSpecifiers([ + '%cStyled %s text %d', + 'color: red', + 'interpolated', + 42, + ]) + ).toEqual(['Styled interpolated text 42']) + }) + + it('should handle escaped percent signs', () => { + expect(stripFormatSpecifiers(['%%'])).toEqual(['%']) + expect(stripFormatSpecifiers(['100%%', 'unused'])).toEqual([ + '100%', + 'unused', + ]) + }) + + it('should preserve excess arguments after all specifiers consumed', () => { + expect(stripFormatSpecifiers(['%s', 'used', 'excess1', 'excess2'])).toEqual( + ['used', 'excess1', 'excess2'] + ) + }) + + it('should handle % at end of string', () => { + expect(stripFormatSpecifiers(['ends with %'])).toEqual(['ends with %']) + }) +}) diff --git a/packages/next/src/server/dev/browser-logs/receive-logs.ts b/packages/next/src/server/dev/browser-logs/receive-logs.ts new file mode 100644 index 0000000000000..4b41c5632813d --- /dev/null +++ b/packages/next/src/server/dev/browser-logs/receive-logs.ts @@ -0,0 +1,572 @@ +import { cyan, dim, red, yellow } from '../../../lib/picocolors' +import type { Project } from '../../../build/swc/types' +import util from 'util' +import { + getConsoleLocation, + getSourceMappedStackFrames, + withLocation, + type MappingContext, +} from './source-map' +import { + type ServerLogEntry, + type LogMethod, + type ConsoleEntry, + UNDEFINED_MARKER, +} from '../../../next-devtools/shared/forward-logs-shared' + +export function restoreUndefined(x: any): any { + if (x === UNDEFINED_MARKER) return undefined + if (Array.isArray(x)) return x.map(restoreUndefined) + if (x && typeof x === 'object') { + for (let k in x) { + x[k] = restoreUndefined(x[k]) + } + } + return x +} + +const methods: Array = [ + 'log', + 'info', + 'warn', + 'debug', + 'table', + 'error', + 'assert', + 'dir', + 'dirxml', + 'group', + 'groupCollapsed', + 'groupEnd', +] + +const methodsToSkipInspect = new Set([ + 'table', + 'dir', + 'dirxml', + 'group', + 'groupCollapsed', + 'groupEnd', +]) + +// we aren't overriding console, we're just making a (slightly convoluted) helper for replaying user console methods +const forwardConsole: typeof console = { + ...console, + ...Object.fromEntries( + methods.map((method) => [ + method, + (...args: Array) => + (console[method] as any)( + ...args.map((arg) => + methodsToSkipInspect.has(method) || + typeof arg !== 'object' || + arg === null + ? arg + : // we hardcode depth:Infinity to allow the true depth to be configured by the serialization done in the browser (which is controlled by user) + util.inspect(arg, { depth: Infinity, colors: true }) + ) + ), + ]) + ), +} + +async function deserializeArgData(arg: any) { + try { + // we want undefined to be represented as it would be in the browser from the user's perspective (otherwise it would be stripped away/shown as null) + if (arg === UNDEFINED_MARKER) { + return restoreUndefined(arg) + } + + return restoreUndefined(JSON.parse(arg)) + } catch { + return arg + } +} + +const colorError = ( + mapped: Awaited>, + config?: { + prefix?: string + applyColor?: boolean + } +) => { + const colorFn = + config?.applyColor === undefined || config.applyColor ? red : (x: T) => x + switch (mapped.kind) { + case 'mapped-stack': + case 'stack': { + return ( + (config?.prefix ? colorFn(config?.prefix) : '') + + `\n${colorFn(mapped.stack)}` + ) + } + case 'with-frame-code': { + return ( + (config?.prefix ? colorFn(config?.prefix) : '') + + `\n${colorFn(mapped.stack)}\n${mapped.frameCode}` + ) + } + // a more sophisticated version of this allows the user to config if they want ignored frames (but we need to be sure to source map them) + case 'all-ignored': { + return config?.prefix ? colorFn(config?.prefix) : '' + } + default: { + } + } + mapped satisfies never +} + +function processConsoleFormatStrings(args: any[]): any[] { + /** + * this handles the case formatting is applied to the console log + * otherwise we will see the format specifier directly in the terminal output + */ + if (args.length > 0 && typeof args[0] === 'string') { + const formatString = args[0] + if ( + formatString.includes('%s') || + formatString.includes('%d') || + formatString.includes('%i') || + formatString.includes('%f') || + formatString.includes('%o') || + formatString.includes('%O') || + formatString.includes('%c') + ) { + try { + const formatted = util.format(...args) + return [formatted] + } catch { + return args + } + } + } + return args +} + +// in the case of logging errors, we want to strip formatting +// modifiers since we apply our own custom coloring to error +// stacks and code blocks, and otherwise it would conflict +// and cause awful output +export function stripFormatSpecifiers(args: any[]): any[] { + if (args.length === 0 || typeof args[0] !== 'string') return args + + const fmtIn = String(args[0]) + const rest = args.slice(1) + + if (!fmtIn.includes('%')) return args + + let fmtOut = '' + let argPtr = 0 + + for (let i = 0; i < fmtIn.length; i++) { + if (fmtIn[i] !== '%') { + fmtOut += fmtIn[i] + continue + } + + if (fmtIn[i + 1] === '%') { + fmtOut += '%' + i++ + continue + } + + const token = fmtIn[++i] + + if (!token) { + fmtOut += '%' + continue + } + + if ('csdifoOj'.includes(token) || token === 'O') { + if (argPtr < rest.length) { + if (token === 'c') { + argPtr++ + } else if (token === 'o' || token === 'O' || token === 'j') { + const obj = rest[argPtr++] + fmtOut += util.inspect(obj, { depth: 2, colors: false }) + } else { + // string(...) is safe for remaining specifiers + fmtOut += String(rest[argPtr++]) + } + } + continue + } + + fmtOut += '%' + token + } + + const result = [fmtOut] + if (argPtr < rest.length) { + result.push(...rest.slice(argPtr)) + } + + return result +} + +async function prepareFormattedErrorArgs( + entry: Extract, + ctx: MappingContext, + distDir: string +) { + const mapped = await getSourceMappedStackFrames(entry.stack, ctx, distDir) + return [colorError(mapped, { prefix: entry.prefix })] +} + +async function prepareConsoleArgs( + entry: Extract, + ctx: MappingContext, + distDir: string +) { + const deserialized = await Promise.all( + entry.args.map(async (arg) => { + if (arg.kind === 'arg') { + const data = await deserializeArgData(arg.data) + if (entry.method === 'warn' && typeof data === 'string') { + return yellow(data) + } + return data + } + if (!arg.stack) return red(arg.prefix) + const mapped = await getSourceMappedStackFrames(arg.stack, ctx, distDir) + return colorError(mapped, { prefix: arg.prefix, applyColor: false }) + }) + ) + + return processConsoleFormatStrings(deserialized) +} + +async function prepareConsoleErrorArgs( + entry: Extract, + ctx: MappingContext, + distDir: string +) { + const deserialized = await Promise.all( + entry.args.map(async (arg) => { + if (arg.kind === 'arg') { + if (arg.isRejectionMessage) return red(arg.data) + return deserializeArgData(arg.data) + } + if (!arg.stack) return red(arg.prefix) + const mapped = await getSourceMappedStackFrames(arg.stack, ctx, distDir) + return colorError(mapped, { prefix: arg.prefix }) + }) + ) + + const mappedStack = await getSourceMappedStackFrames( + entry.consoleErrorStack, + ctx, + distDir + ) + + /** + * don't show the stack + codeblock when there are errors present, since: + * - it will look overwhelming to see 2 stacks and 2 code blocks + * - the user already knows where the console.error is at because we append the location + */ + const location = getConsoleLocation(mappedStack) + if (entry.args.some((a) => a.kind === 'formatted-error-arg')) { + const result = stripFormatSpecifiers(deserialized) + if (location) { + result.push(dim(`(${location})`)) + } + return result + } + const result = [ + ...processConsoleFormatStrings(deserialized), + colorError(mappedStack), + ] + if (location) { + result.push(dim(`(${location})`)) + } + return result +} + +async function handleTable( + entry: ConsoleEntry, + browserPrefix: string, + ctx: MappingContext, + distDir: string +) { + const deserializedArgs = await Promise.all( + entry.args.map(async (arg: any) => { + if (arg.kind === 'formatted-error-arg') { + return { stack: arg.stack } + } + return deserializeArgData(arg.data) + }) + ) + + const location = await (async () => { + if (!entry.consoleMethodStack) { + return + } + const frames = await getSourceMappedStackFrames( + entry.consoleMethodStack, + ctx, + distDir + ) + return getConsoleLocation(frames) + })() + + // we can't inline pass browser prefix, but it looks better multiline for table anyways + forwardConsole.log(browserPrefix) + forwardConsole.table(...deserializedArgs) + if (location) { + forwardConsole.log(dim(`(${location})`)) + } +} + +async function handleTrace( + entry: ConsoleEntry, + browserPrefix: string, + ctx: MappingContext, + distDir: string +) { + const deserializedArgs = await Promise.all( + entry.args.map(async (arg: any) => { + if (arg.kind === 'formatted-error-arg') { + if (!arg.stack) return red(arg.prefix) + const mapped = await getSourceMappedStackFrames(arg.stack, ctx, distDir) + return colorError(mapped, { prefix: arg.prefix }) + } + return deserializeArgData(arg.data) + }) + ) + + if (!entry.consoleMethodStack) { + forwardConsole.log( + browserPrefix, + ...deserializedArgs, + '[Trace unavailable]' + ) + return + } + + // TODO(rob): refactor so we can re-use result and not re-run the entire source map to avoid trivial post processing + const [mapped, mappedIgnored] = await Promise.all([ + getSourceMappedStackFrames(entry.consoleMethodStack, ctx, distDir, false), + getSourceMappedStackFrames(entry.consoleMethodStack, ctx, distDir), + ]) + + const location = getConsoleLocation(mappedIgnored) + forwardConsole.log( + browserPrefix, + ...deserializedArgs, + `\n${mapped.stack}`, + ...(location ? [`\n${dim(`(${location})`)}`] : []) + ) +} + +async function handleDir( + entry: ConsoleEntry, + browserPrefix: string, + ctx: MappingContext, + distDir: string +) { + const loggableEntry = await prepareConsoleArgs(entry, ctx, distDir) + const consoleMethod = + (forwardConsole as any)[entry.method] || forwardConsole.log + + if (entry.consoleMethodStack) { + const mapped = await getSourceMappedStackFrames( + entry.consoleMethodStack, + ctx, + distDir + ) + const location = dim(`(${getConsoleLocation(mapped)})`) + const originalWrite = process.stdout.write.bind(process.stdout) + let captured = '' + process.stdout.write = (chunk) => { + captured += chunk + return true + } + try { + consoleMethod(...loggableEntry) + } finally { + process.stdout.write = originalWrite + } + const preserved = captured.replace(/\r?\n$/, '') + originalWrite(`${browserPrefix}${preserved} ${location}\n`) + return + } + consoleMethod(browserPrefix, ...loggableEntry) +} + +async function handleDefaultConsole( + entry: ConsoleEntry, + browserPrefix: string, + ctx: MappingContext, + distDir: string, + config: boolean | { logDepth?: number; showSourceLocation?: boolean } +) { + const loggableEntry = await prepareConsoleArgs(entry, ctx, distDir) + const withStackEntry = await withLocation( + { + original: loggableEntry, + stack: (entry as any).consoleMethodStack || null, + }, + ctx, + distDir, + config + ) + const consoleMethod = forwardConsole[entry.method] || forwardConsole.log + ;(consoleMethod as (...args: any[]) => void)(browserPrefix, ...withStackEntry) +} + +export async function handleLog( + entries: ServerLogEntry[], + ctx: MappingContext, + distDir: string, + config: boolean | { logDepth?: number; showSourceLocation?: boolean } +): Promise { + const browserPrefix = cyan('[browser]') + + for (const entry of entries) { + try { + switch (entry.kind) { + case 'console': { + switch (entry.method) { + case 'table': { + // timeout based abort on source mapping result + await handleTable(entry, browserPrefix, ctx, distDir) + break + } + // ignore frames + case 'trace': { + await handleTrace(entry, browserPrefix, ctx, distDir) + break + } + case 'dir': { + await handleDir(entry, browserPrefix, ctx, distDir) + break + } + // xml log thing maybe needs an impl + + // [browser] undefined (app/page.tsx:8:11) console.group + // check console assert + default: { + await handleDefaultConsole( + entry, + browserPrefix, + ctx, + distDir, + config + ) + } + } + break + } + // any logged errors are anything that are logged as "red" in the browser but aren't only an Error (console.error, Promise.reject(100)) + case 'any-logged-error': { + const consoleArgs = await prepareConsoleErrorArgs(entry, ctx, distDir) + forwardConsole.error(browserPrefix, ...consoleArgs) + break + } + // formatted error is an explicit error event (rejections, uncaught errors) + case 'formatted-error': { + const formattedArgs = await prepareFormattedErrorArgs( + entry, + ctx, + distDir + ) + forwardConsole.error(browserPrefix, ...formattedArgs) + break + } + default: { + } + } + } catch { + switch (entry.kind) { + case 'any-logged-error': { + const consoleArgs = await prepareConsoleErrorArgs(entry, ctx, distDir) + forwardConsole.error(browserPrefix, ...consoleArgs) + break + } + case 'console': { + const consoleMethod = + forwardConsole[entry.method] || forwardConsole.log + const consoleArgs = await prepareConsoleArgs(entry, ctx, distDir) + ;(consoleMethod as (...args: any[]) => void)( + browserPrefix, + ...consoleArgs + ) + break + } + case 'formatted-error': { + forwardConsole.error(browserPrefix, `${entry.prefix}\n`, entry.stack) + break + } + default: { + } + } + } + } +} + +// the data is used later when we need to get sourcemaps for error stacks +export async function receiveBrowserLogsWebpack(opts: { + entries: ServerLogEntry[] + router: 'app' | 'pages' + sourceType?: 'server' | 'edge-server' + clientStats: () => any + serverStats: () => any + edgeServerStats: () => any + rootDirectory: string + distDir: string + config: boolean | { logDepth?: number; showSourceLocation?: boolean } +}): Promise { + const { + entries, + router, + sourceType, + clientStats, + serverStats, + edgeServerStats, + rootDirectory, + distDir, + } = opts + + const isAppDirectory = router === 'app' + const isServer = sourceType === 'server' + const isEdgeServer = sourceType === 'edge-server' + + const ctx: MappingContext = { + bundler: 'webpack', + isServer, + isEdgeServer, + isAppDirectory, + clientStats, + serverStats, + edgeServerStats, + rootDirectory, + } + + await handleLog(entries, ctx, distDir, opts.config) +} + +export async function receiveBrowserLogsTurbopack(opts: { + entries: ServerLogEntry[] + router: 'app' | 'pages' + sourceType?: 'server' | 'edge-server' + project: Project + projectPath: string + distDir: string + config: boolean | { logDepth?: number; showSourceLocation?: boolean } +}): Promise { + const { entries, router, sourceType, project, projectPath, distDir } = opts + + const isAppDirectory = router === 'app' + const isServer = sourceType === 'server' + const isEdgeServer = sourceType === 'edge-server' + + const ctx: MappingContext = { + bundler: 'turbopack', + project, + projectPath, + isServer, + isEdgeServer, + isAppDirectory, + } + + await handleLog(entries, ctx, distDir, opts.config) +} diff --git a/packages/next/src/server/dev/browser-logs/source-map.ts b/packages/next/src/server/dev/browser-logs/source-map.ts new file mode 100644 index 0000000000000..01e6c8f83fbdb --- /dev/null +++ b/packages/next/src/server/dev/browser-logs/source-map.ts @@ -0,0 +1,301 @@ +import type { StackFrame } from 'stacktrace-parser' +import { getOriginalStackFrames as getOriginalStackFramesWebpack } from '../middleware-webpack' +import { getOriginalStackFrames as getOriginalStackFramesTurbopack } from '../middleware-turbopack' +import type { Project } from '../../../build/swc/types' +import { dim } from '../../../lib/picocolors' +import { parseStack } from '../../lib/parse-stack' +import path from 'path' +import { LRUCache } from '../../lib/lru-cache' + +type WebpackMappingContext = { + bundler: 'webpack' + isServer: boolean + isEdgeServer: boolean + isAppDirectory: boolean + clientStats: () => any + serverStats: () => any + edgeServerStats: () => any + rootDirectory: string +} + +type TurbopackMappingContext = { + bundler: 'turbopack' + isServer: boolean + isEdgeServer: boolean + isAppDirectory: boolean + project: Project + projectPath: string +} + +export type MappingContext = WebpackMappingContext | TurbopackMappingContext + +// TODO: handle server vs browser error source mapping correctly +export async function mapFramesUsingBundler( + frames: StackFrame[], + ctx: MappingContext +) { + switch (ctx.bundler) { + case 'webpack': { + const { + isServer, + isEdgeServer, + isAppDirectory, + clientStats, + serverStats, + edgeServerStats, + rootDirectory, + } = ctx + const res = await getOriginalStackFramesWebpack({ + isServer, + isEdgeServer, + isAppDirectory, + frames, + clientStats, + serverStats, + edgeServerStats, + rootDirectory, + }) + return res + } + case 'turbopack': { + const { project, projectPath, isServer, isEdgeServer, isAppDirectory } = + ctx + const res = await getOriginalStackFramesTurbopack({ + project, + projectPath, + frames, + isServer, + isEdgeServer, + isAppDirectory, + }) + + return res + } + default: { + return null! + } + } +} + +// converts _next/static/chunks/... to file:///.next/static/chunks/... for parseStack +// todo: where does next dev overlay handle this case and re-use that logic +function preprocessStackTrace(stackTrace: string, distDir?: string): string { + return stackTrace + .split('\n') + .map((line) => { + const match = line.match(/^(\s*at\s+.*?)\s+\(([^)]+)\)$/) + if (match) { + const [, prefix, location] = match + + if (location.startsWith('_next/static/') && distDir) { + const normalizedDistDir = distDir + .replace(/\\/g, '/') + .replace(/\/$/, '') + + const absolutePath = + normalizedDistDir + '/' + location.slice('_next/'.length) + const fileUrl = `file://${path.resolve(absolutePath)}` + + return `${prefix} (${fileUrl})` + } + } + + return line + }) + .join('\n') +} + +const cache = new LRUCache< + Awaited> +>(25) +async function getSourceMappedStackFramesInternal( + stackTrace: string, + ctx: MappingContext, + distDir: string, + ignore = true +) { + try { + const normalizedStack = preprocessStackTrace(stackTrace, distDir) + const frames = parseStack(normalizedStack, distDir) + + if (frames.length === 0) { + return { + kind: 'stack' as const, + stack: stackTrace, + } + } + + const mappingResults = await mapFramesUsingBundler(frames, ctx) + + const processedFrames = mappingResults + .map((result, index) => ({ + result, + originalFrame: frames[index], + })) + .map(({ result, originalFrame }) => { + if (result.status === 'rejected') { + return { + kind: 'rejected' as const, + frameText: formatStackFrame(originalFrame), + codeFrame: null, + } + } + + const { originalStackFrame, originalCodeFrame } = result.value + if (originalStackFrame?.ignored && ignore) { + return { + kind: 'ignored' as const, + } + } + + // should we apply this generally to dev overlay (dev overlay does not ignore chrome-extension://) + if (originalStackFrame?.file?.startsWith('chrome-extension://')) { + return { + kind: 'ignored' as const, + } + } + + return { + kind: 'success' as const, + // invariant: if result is not rejected and not ignored, then original stack frame exists + // verifiable by tracing `getOriginalStackFrame`. The invariant exists because of bad types + frameText: formatStackFrame(originalStackFrame!), + codeFrame: originalCodeFrame, + } + }) + + const allIgnored = processedFrames.every( + (frame) => frame.kind === 'ignored' + ) + + // we want to handle **all** ignored vs all/some rejected differently + // if all are ignored we should show no frames + // if all are rejected, we want to fallback to showing original stack frames + if (allIgnored) { + return { + kind: 'all-ignored' as const, + } + } + + const filteredFrames = processedFrames.filter( + (frame) => frame.kind !== 'ignored' + ) + + if (filteredFrames.length === 0) { + return { + kind: 'stack' as const, + stack: stackTrace, + } + } + + const stackOutput = filteredFrames + .map((frame) => frame.frameText) + .join('\n') + const firstFrameCode = filteredFrames.find( + (frame) => frame.codeFrame + )?.codeFrame + + if (firstFrameCode) { + return { + kind: 'with-frame-code' as const, + frameCode: firstFrameCode, + stack: stackOutput, + frames: filteredFrames, + } + } + // i don't think this a real case, but good for exhaustion + return { + kind: 'mapped-stack' as const, + stack: stackOutput, + frames: filteredFrames, + } + } catch (error) { + return { + kind: 'stack' as const, + stack: stackTrace, + } + } +} + +// todo: cache the actual async call, not the wrapper with post processing +export async function getSourceMappedStackFrames( + stackTrace: string, + ctx: MappingContext, + distDir: string, + ignore = true +) { + const cacheKey = `sm_${stackTrace}-${ctx.bundler}-${ctx.isAppDirectory}-${ctx.isEdgeServer}-${ctx.isServer}-${distDir}-${ignore}` + + const cacheItem = cache.get(cacheKey) + if (cacheItem) { + return cacheItem + } + + const result = await getSourceMappedStackFramesInternal( + stackTrace, + ctx, + distDir, + ignore + ) + cache.set(cacheKey, result) + return result +} + +function formatStackFrame(frame: StackFrame): string { + const functionName = frame.methodName || '' + const location = + frame.file && frame.lineNumber + ? `${frame.file}:${frame.lineNumber}${frame.column ? `:${frame.column}` : ''}` + : frame.file || '' + + return ` at ${functionName} (${location})` +} + +// appends the source mapped location of the console method +export const withLocation = async ( + { + original, + stack, + }: { + original: Array + stack: string | null + }, + ctx: MappingContext, + distDir: string, + config: boolean | { logDepth?: number; showSourceLocation?: boolean } +) => { + if (typeof config === 'object' && config.showSourceLocation === false) { + return original + } + if (!stack) { + return original + } + + const res = await getSourceMappedStackFrames(stack, ctx, distDir) + const location = getConsoleLocation(res) + + if (!location) { + return original + } + + return [...original, dim(`(${location})`)] +} + +export const getConsoleLocation = ( + mapped: Awaited> +) => { + if (mapped.kind !== 'mapped-stack' && mapped.kind !== 'with-frame-code') { + return null + } + + const first = mapped.frames.at(0) + + if (!first) { + return null + } + + // we don't want to show the name of parent function (at thing in stack), just source location for minimal noise + const match = first.frameText.match(/\(([^)]+)\)/) + const locationText = match ? match[1] : first.frameText + return locationText +} diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts index fca837d3836d9..9e123586c18f3 100644 --- a/packages/next/src/server/dev/hot-reloader-turbopack.ts +++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts @@ -98,6 +98,7 @@ import { getDisableDevIndicatorMiddleware } from '../../next-devtools/server/dev import { getRestartDevServerMiddleware } from '../../next-devtools/server/restart-dev-server-middleware' import { backgroundLogCompilationEvents } from '../../shared/lib/turbopack/compilation-events' import { getSupportedBrowsers } from '../../build/utils' +import { receiveBrowserLogsTurbopack } from './browser-logs/receive-logs' const wsServer = new ws.Server({ noServer: true }) const isTestMode = !!( @@ -752,7 +753,7 @@ export async function createHotReloaderTurbopack( clients.delete(client) }) - client.addEventListener('message', ({ data }) => { + client.addEventListener('message', async ({ data }) => { const parsedData = JSON.parse( typeof data !== 'string' ? data.toString() : data ) @@ -780,6 +781,7 @@ export async function createHotReloaderTurbopack( } ) break + case 'client-error': // { errorCount, clientId } case 'client-warning': // { warningCount, clientId } case 'client-success': // { clientId } @@ -806,6 +808,20 @@ export async function createHotReloaderTurbopack( case 'client-added-page': // TODO break + case 'browser-logs': { + if (nextConfig.experimental.browserDebugInfoInTerminal) { + await receiveBrowserLogsTurbopack({ + entries: parsedData.entries, + router: parsedData.router, + sourceType: parsedData.sourceType, + project, + projectPath, + distDir, + config: nextConfig.experimental.browserDebugInfoInTerminal, + }) + } + break + } default: // Might be a Turbopack message... diff --git a/packages/next/src/server/dev/hot-reloader-webpack.ts b/packages/next/src/server/dev/hot-reloader-webpack.ts index 8f28110e4b923..418eb49049826 100644 --- a/packages/next/src/server/dev/hot-reloader-webpack.ts +++ b/packages/next/src/server/dev/hot-reloader-webpack.ts @@ -90,6 +90,7 @@ import { getDisableDevIndicatorMiddleware } from '../../next-devtools/server/dev import getWebpackBundler from '../../shared/lib/get-webpack-bundler' import { getRestartDevServerMiddleware } from '../../next-devtools/server/restart-dev-server-middleware' import { checkPersistentCacheInvalidationAndCleanup } from '../../build/webpack/cache-invalidation' +import { receiveBrowserLogsWebpack } from './browser-logs/receive-logs' const MILLISECONDS_IN_NANOSECOND = BigInt(1_000_000) @@ -437,7 +438,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { this.onDemandEntries?.onHMR(client, () => this.hmrServerError) callback(client) - client.addEventListener('message', ({ data }) => { + client.addEventListener('message', async ({ data }) => { data = typeof data !== 'string' ? data.toString() : data try { @@ -567,6 +568,22 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface { ) break } + case 'browser-logs': { + if (this.config.experimental.browserDebugInfoInTerminal) { + await receiveBrowserLogsWebpack({ + entries: payload.entries, + router: payload.router, + sourceType: payload.sourceType, + clientStats: () => this.clientStats, + serverStats: () => this.serverStats, + edgeServerStats: () => this.edgeServerStats, + rootDirectory: this.dir, + distDir: this.distDir, + config: this.config.experimental.browserDebugInfoInTerminal, + }) + } + break + } default: { break } diff --git a/packages/next/src/server/dev/middleware-turbopack.ts b/packages/next/src/server/dev/middleware-turbopack.ts index 594596f2f04b9..56cc14b3f7459 100644 --- a/packages/next/src/server/dev/middleware-turbopack.ts +++ b/packages/next/src/server/dev/middleware-turbopack.ts @@ -350,30 +350,14 @@ export function getOverlayMiddleware({ }) const request = JSON.parse(body) as OriginalStackFramesRequest - const stackFrames = createStackFrames(request) - const result: OriginalStackFramesResponse = await Promise.all( - stackFrames.map(async (frame) => { - try { - const stackFrame = await createOriginalStackFrame( - project, - projectPath, - frame - ) - if (stackFrame === null) { - return { - status: 'rejected', - reason: 'Failed to create original stack frame', - } - } - return { status: 'fulfilled', value: stackFrame } - } catch (error) { - return { - status: 'rejected', - reason: inspect(error, { colors: false }), - } - } - }) - ) + const result = await getOriginalStackFrames({ + project, + projectPath, + frames: request.frames, + isServer: request.isServer, + isEdgeServer: request.isEdgeServer, + isAppDirectory: request.isAppDirectory, + }) return middlewareResponse.json(res, result) } else if (pathname === '/__nextjs_launch-editor') { @@ -473,3 +457,50 @@ export function getSourceMapMiddleware(project: Project) { middlewareResponse.noContent(res) } } + +export async function getOriginalStackFrames({ + project, + projectPath, + frames, + isServer, + isEdgeServer, + isAppDirectory, +}: { + project: Project + projectPath: string + frames: StackFrame[] + isServer: boolean + isEdgeServer: boolean + isAppDirectory: boolean +}): Promise { + const stackFrames = createStackFrames({ + frames, + isServer, + isEdgeServer, + isAppDirectory, + }) + + return Promise.all( + stackFrames.map(async (frame) => { + try { + const stackFrame = await createOriginalStackFrame( + project, + projectPath, + frame + ) + if (stackFrame === null) { + return { + status: 'rejected', + reason: 'Failed to create original stack frame', + } + } + return { status: 'fulfilled', value: stackFrame } + } catch (error) { + return { + status: 'rejected', + reason: inspect(error, { colors: false }), + } + } + }) + ) +} diff --git a/packages/next/src/server/dev/middleware-webpack.ts b/packages/next/src/server/dev/middleware-webpack.ts index 2a324ec2cf257..7fef2f16a3774 100644 --- a/packages/next/src/server/dev/middleware-webpack.ts +++ b/packages/next/src/server/dev/middleware-webpack.ts @@ -377,7 +377,7 @@ async function getSource( return undefined } -function getOriginalStackFrames({ +export function getOriginalStackFrames({ isServer, isEdgeServer, isAppDirectory, @@ -397,31 +397,30 @@ function getOriginalStackFrames({ rootDirectory: string }): Promise { return Promise.all( - frames.map( - (frame): Promise => - getOriginalStackFrame({ - isServer, - isEdgeServer, - isAppDirectory, - frame, - clientStats, - serverStats, - edgeServerStats, - rootDirectory, - }).then( - (value) => { - return { - status: 'fulfilled', - value, - } - }, - (reason) => { - return { - status: 'rejected', - reason: inspect(reason, { colors: false }), - } + frames.map((frame) => + getOriginalStackFrame({ + isServer, + isEdgeServer, + isAppDirectory, + frame, + clientStats, + serverStats, + edgeServerStats, + rootDirectory, + }).then( + (value) => { + return { + status: 'fulfilled' as const, + value, } - ) + }, + (reason) => { + return { + status: 'rejected' as const, + reason: inspect(reason, { colors: false }), + } + } + ) ) ) } diff --git a/packages/next/src/server/lib/parse-stack.ts b/packages/next/src/server/lib/parse-stack.ts index 9918d5574ecb7..f60ba5d6116ad 100644 --- a/packages/next/src/server/lib/parse-stack.ts +++ b/packages/next/src/server/lib/parse-stack.ts @@ -3,7 +3,10 @@ import type { StackFrame } from 'next/dist/compiled/stacktrace-parser' const regexNextStatic = /\/_next(\/static\/.+)/ -export function parseStack(stack: string): StackFrame[] { +export function parseStack( + stack: string, + distDir = process.env.__NEXT_DIST_DIR +): StackFrame[] { if (!stack) return [] // throw away eval information that stacktrace-parser doesn't support @@ -28,11 +31,12 @@ export function parseStack(stack: string): StackFrame[] { const url = new URL(frame.file!) const res = regexNextStatic.exec(url.pathname) if (res) { - const distDir = process.env.__NEXT_DIST_DIR + const effectiveDistDir = distDir ?.replace(/\\/g, '/') ?.replace(/\/$/, '') - if (distDir) { - frame.file = 'file://' + distDir.concat(res.pop()!) + url.search + if (effectiveDistDir) { + frame.file = + 'file://' + effectiveDistDir.concat(res.pop()!) + url.search } } } catch {} diff --git a/packages/next/taskfile.js b/packages/next/taskfile.js index 1ad499ecb573c..551a5512bf72a 100644 --- a/packages/next/taskfile.js +++ b/packages/next/taskfile.js @@ -2256,6 +2256,18 @@ export async function ncc_https_proxy_agent(task, opts) { .target('src/compiled/https-proxy-agent') } +externals['safe-stable-stringify'] = 'next/dist/compiled/safe-stable-stringify' +export async function ncc_safe_stable_stringify(task, opts) { + await task + .source(relative(__dirname, require.resolve('safe-stable-stringify'))) + .ncc({ + packageName: 'safe-stable-stringify', + externals, + target: 'es5', + }) + .target('src/compiled/safe-stable-stringify') +} + export async function precompile(task, opts) { await task.parallel( ['browser_polyfills', 'copy_ncced', 'copy_styled_jsx_assets'], @@ -2275,6 +2287,7 @@ export async function ncc(task, opts) { .clear('src/compiled') .parallel( [ + 'ncc_safe_stable_stringify', 'ncc_amp_optimizer', 'ncc_node_html_parser', 'ncc_napirs_triples', diff --git a/packages/next/types/$$compiled.internal.d.ts b/packages/next/types/$$compiled.internal.d.ts index 80841e9f5366c..00322b0236ce6 100644 --- a/packages/next/types/$$compiled.internal.d.ts +++ b/packages/next/types/$$compiled.internal.d.ts @@ -736,6 +736,11 @@ declare module 'next/dist/compiled/anser' { export = m } +declare module 'next/dist/compiled/safe-stable-stringify' { + import * as m from 'safe-stable-stringify' + export = m +} + declare module 'next/dist/compiled/css.escape' { export = CSS.escape } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index deea611635550..93802a24ef420 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -516,7 +516,7 @@ importers: version: react-server-dom-webpack@0.0.0-experimental-a7a11657-20250708(react-dom@19.2.0-canary-a7a11657-20250708(react@19.2.0-canary-a7a11657-20250708))(react@19.2.0-canary-a7a11657-20250708)(webpack@5.98.0(@swc/core@1.11.24(@swc/helpers@0.5.15))) react-ssr-prepass: specifier: 1.0.8 - version: 1.0.8(react-is@19.2.0-canary-fa3feba6-20250623)(react@19.2.0-canary-a7a11657-20250708) + version: 1.0.8(react-is@19.2.0-canary-cee7939b-20250625)(react@19.2.0-canary-a7a11657-20250708) react-virtualized: specifier: 9.22.3 version: 9.22.3(react-dom@19.2.0-canary-a7a11657-20250708(react@19.2.0-canary-a7a11657-20250708))(react@19.2.0-canary-a7a11657-20250708) @@ -651,13 +651,13 @@ importers: dependencies: '@mantine/core': specifier: ^7.10.1 - version: 7.10.1(@mantine/hooks@7.11.2(react@19.2.0-canary-fa3feba6-20250623))(@types/react@19.1.1)(react-dom@19.2.0-canary-fa3feba6-20250623(react@19.2.0-canary-fa3feba6-20250623))(react@19.2.0-canary-fa3feba6-20250623) + version: 7.10.1(@mantine/hooks@7.11.2(react@19.2.0-canary-cee7939b-20250625))(@types/react@19.1.1)(react-dom@19.2.0-canary-cee7939b-20250625(react@19.2.0-canary-cee7939b-20250625))(react@19.2.0-canary-cee7939b-20250625) lodash-es: specifier: ^4.17.21 version: 4.17.21 lucide-react: specifier: ^0.383.0 - version: 0.383.0(react@19.2.0-canary-fa3feba6-20250623) + version: 0.383.0(react@19.2.0-canary-cee7939b-20250625) mermaid: specifier: ^10.9.1 version: 10.9.1 @@ -1468,6 +1468,9 @@ importers: regenerator-runtime: specifier: 0.13.4 version: 0.13.4 + safe-stable-stringify: + specifier: 2.5.0 + version: 2.5.0 sass-loader: specifier: 15.0.0 version: 15.0.0(@rspack/core@1.4.5(@swc/helpers@0.5.15))(sass@1.77.8)(webpack@5.98.0(@swc/core@1.11.24(@swc/helpers@0.5.15))(esbuild@0.24.2)) @@ -1669,7 +1672,7 @@ importers: version: 2.2.1(webpack@5.98.0(@swc/core@1.11.24(@swc/helpers@0.5.15))) '@mdx-js/react': specifier: '>=0.15.0' - version: 2.2.1(react@19.2.0-canary-fa3feba6-20250623) + version: 2.2.1(react@19.2.0-canary-cee7939b-20250625) source-map: specifier: ^0.7.0 version: 0.7.3 @@ -13928,7 +13931,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qs@6.11.0: @@ -14040,16 +14042,16 @@ packages: peerDependencies: react: 19.2.0-canary-a7a11657-20250708 - react-dom@19.2.0-canary-fa3feba6-20250623: - resolution: {integrity: sha512-a/OyVKA4wJo1eBL82KJOBho8x2p2rZe/pubH94hGCBMfsty+x0xyB8KbE/YtNhXzMJaJ28PvvPceZ6WbqbDQlg==} + react-dom@19.2.0-canary-cee7939b-20250625: + resolution: {integrity: sha512-cKXbvGw8b+t+uxJZfNBK0oboEee5wYS3f8Yeu22PlW3eECtfDnM2pqSbcB6LvOVr3fyDMM4CIWesK01d9BXj7w==} peerDependencies: react: 19.2.0-canary-a7a11657-20250708 react-is@19.2.0-canary-a7a11657-20250708: resolution: {integrity: sha512-19g7E/3eQbITMesIWYnBA3FX5hwCgqtbcsdOQ3k/NjmLK5HXFU3rSZV0Enil7bL2thu2pwYERXjQo+BmgZohZw==} - react-is@19.2.0-canary-fa3feba6-20250623: - resolution: {integrity: sha512-BktwFemMIkQdqE8Ijv9BzB7rx0yAk4xndThBAi8cLnkMOd6Imm+XBeTweHHuMhehtTK623DIsWT1roVRBUby8Q==} + react-is@19.2.0-canary-cee7939b-20250625: + resolution: {integrity: sha512-AsmZ5fWwYOQXykkjT/OBo2n30UH+014Jefhv9qiKxuw8AqD5QMi47Px3Fw86YLtZPOxWQOsvVMqHKwFvW35Q/w==} react-lifecycles-compat@3.0.4: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} @@ -14160,8 +14162,8 @@ packages: resolution: {integrity: sha512-r/Da+Thg79ljp0aVkxvITXx2J1FfixiHLRMoGQeIaql7as56YfG7wkiagKy2RpPdX+X8AOgTS95bGMFg1jEJ5g==} engines: {node: '>=0.10.0'} - react@19.2.0-canary-fa3feba6-20250623: - resolution: {integrity: sha512-85ryAyG3hpmDCR0PJy8D9PN10XyiBs0U948be5uGj8FqT48NGHujMlzEKrcBgzfYrsuom6lJVO7Ly2iag+Bj5g==} + react@19.2.0-canary-cee7939b-20250625: + resolution: {integrity: sha512-MDouKrROK4xfih07q2fuBiofvnV1oSPs5Bm/+vulj3XCTG1FiGaG45tmWV0re0w9sRj8wi84rEXMIw1Gw3/IVg==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -14665,6 +14667,10 @@ packages: resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} engines: {node: '>= 0.4'} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -15396,7 +15402,7 @@ packages: superagent@3.8.3: resolution: {integrity: sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==} engines: {node: '>= 4.0'} - deprecated: Please upgrade to v7.0.2+ of superagent. We have fixed numerous issues with streams, form-data, attach(), filesystem errors not bubbling up (ENOENT on attach()), and all tests are now passing. See the releases tab for more information at . + deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net superstruct@1.0.3: resolution: {integrity: sha512-8iTn3oSS8nRGn+C2pgXSKPI3jmpm6FExNazNpjvqS6ZUJQCej3PUXEKM8NjHBOs54ExM+LPW/FBRhymrdcCiSg==} @@ -18867,11 +18873,11 @@ snapshots: '@floating-ui/core': 1.7.2 '@floating-ui/utils': 0.2.10 - '@floating-ui/react-dom@2.1.0(react-dom@19.2.0-canary-fa3feba6-20250623(react@19.2.0-canary-fa3feba6-20250623))(react@19.2.0-canary-fa3feba6-20250623)': + '@floating-ui/react-dom@2.1.0(react-dom@19.2.0-canary-cee7939b-20250625(react@19.2.0-canary-cee7939b-20250625))(react@19.2.0-canary-cee7939b-20250625)': dependencies: '@floating-ui/dom': 1.6.5 - react: 19.2.0-canary-fa3feba6-20250623 - react-dom: 19.2.0-canary-fa3feba6-20250623(react@19.2.0-canary-fa3feba6-20250623) + react: 19.2.0-canary-cee7939b-20250625 + react-dom: 19.2.0-canary-cee7939b-20250625(react@19.2.0-canary-cee7939b-20250625) '@floating-ui/react-dom@2.1.4(react-dom@19.2.0-canary-a7a11657-20250708(react@19.2.0-canary-a7a11657-20250708))(react@19.2.0-canary-a7a11657-20250708)': dependencies: @@ -18879,12 +18885,12 @@ snapshots: react: 19.2.0-canary-a7a11657-20250708 react-dom: 19.2.0-canary-a7a11657-20250708(react@19.2.0-canary-a7a11657-20250708) - '@floating-ui/react@0.26.16(react-dom@19.2.0-canary-fa3feba6-20250623(react@19.2.0-canary-fa3feba6-20250623))(react@19.2.0-canary-fa3feba6-20250623)': + '@floating-ui/react@0.26.16(react-dom@19.2.0-canary-cee7939b-20250625(react@19.2.0-canary-cee7939b-20250625))(react@19.2.0-canary-cee7939b-20250625)': dependencies: - '@floating-ui/react-dom': 2.1.0(react-dom@19.2.0-canary-fa3feba6-20250623(react@19.2.0-canary-fa3feba6-20250623))(react@19.2.0-canary-fa3feba6-20250623) + '@floating-ui/react-dom': 2.1.0(react-dom@19.2.0-canary-cee7939b-20250625(react@19.2.0-canary-cee7939b-20250625))(react@19.2.0-canary-cee7939b-20250625) '@floating-ui/utils': 0.2.2 - react: 19.2.0-canary-fa3feba6-20250623 - react-dom: 19.2.0-canary-fa3feba6-20250623(react@19.2.0-canary-fa3feba6-20250623) + react: 19.2.0-canary-cee7939b-20250625 + react-dom: 19.2.0-canary-cee7939b-20250625(react@19.2.0-canary-cee7939b-20250625) tabbable: 6.2.0 '@floating-ui/utils@0.2.10': {} @@ -19943,23 +19949,23 @@ snapshots: dependencies: call-bind: 1.0.7 - '@mantine/core@7.10.1(@mantine/hooks@7.11.2(react@19.2.0-canary-fa3feba6-20250623))(@types/react@19.1.1)(react-dom@19.2.0-canary-fa3feba6-20250623(react@19.2.0-canary-fa3feba6-20250623))(react@19.2.0-canary-fa3feba6-20250623)': + '@mantine/core@7.10.1(@mantine/hooks@7.11.2(react@19.2.0-canary-cee7939b-20250625))(@types/react@19.1.1)(react-dom@19.2.0-canary-cee7939b-20250625(react@19.2.0-canary-cee7939b-20250625))(react@19.2.0-canary-cee7939b-20250625)': dependencies: - '@floating-ui/react': 0.26.16(react-dom@19.2.0-canary-fa3feba6-20250623(react@19.2.0-canary-fa3feba6-20250623))(react@19.2.0-canary-fa3feba6-20250623) - '@mantine/hooks': 7.11.2(react@19.2.0-canary-fa3feba6-20250623) + '@floating-ui/react': 0.26.16(react-dom@19.2.0-canary-cee7939b-20250625(react@19.2.0-canary-cee7939b-20250625))(react@19.2.0-canary-cee7939b-20250625) + '@mantine/hooks': 7.11.2(react@19.2.0-canary-cee7939b-20250625) clsx: 2.1.1 - react: 19.2.0-canary-fa3feba6-20250623 - react-dom: 19.2.0-canary-fa3feba6-20250623(react@19.2.0-canary-fa3feba6-20250623) - react-number-format: 5.4.0(react-dom@19.2.0-canary-fa3feba6-20250623(react@19.2.0-canary-fa3feba6-20250623))(react@19.2.0-canary-fa3feba6-20250623) - react-remove-scroll: 2.5.10(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623) - react-textarea-autosize: 8.5.3(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623) + react: 19.2.0-canary-cee7939b-20250625 + react-dom: 19.2.0-canary-cee7939b-20250625(react@19.2.0-canary-cee7939b-20250625) + react-number-format: 5.4.0(react-dom@19.2.0-canary-cee7939b-20250625(react@19.2.0-canary-cee7939b-20250625))(react@19.2.0-canary-cee7939b-20250625) + react-remove-scroll: 2.5.10(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625) + react-textarea-autosize: 8.5.3(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625) type-fest: 4.18.3 transitivePeerDependencies: - '@types/react' - '@mantine/hooks@7.11.2(react@19.2.0-canary-fa3feba6-20250623)': + '@mantine/hooks@7.11.2(react@19.2.0-canary-cee7939b-20250625)': dependencies: - react: 19.2.0-canary-fa3feba6-20250623 + react: 19.2.0-canary-cee7939b-20250625 '@manypkg/find-root@1.1.0': dependencies: @@ -20028,11 +20034,11 @@ snapshots: '@types/react': 19.1.1 react: 19.2.0-canary-a7a11657-20250708 - '@mdx-js/react@2.2.1(react@19.2.0-canary-fa3feba6-20250623)': + '@mdx-js/react@2.2.1(react@19.2.0-canary-cee7939b-20250625)': dependencies: '@types/mdx': 2.0.3 '@types/react': 19.1.1 - react: 19.2.0-canary-fa3feba6-20250623 + react: 19.2.0-canary-cee7939b-20250625 '@mdx-js/react@3.1.0(@types/react@19.1.1)(react@19.2.0-canary-a7a11657-20250708)': dependencies: @@ -29024,9 +29030,9 @@ snapshots: lru-cache@7.18.3: {} - lucide-react@0.383.0(react@19.2.0-canary-fa3feba6-20250623): + lucide-react@0.383.0(react@19.2.0-canary-cee7939b-20250625): dependencies: - react: 19.2.0-canary-fa3feba6-20250623 + react: 19.2.0-canary-cee7939b-20250625 lz-string@1.5.0: {} @@ -32237,41 +32243,41 @@ snapshots: react: 19.2.0-canary-a7a11657-20250708 scheduler: 0.27.0-canary-a7a11657-20250708 - react-dom@19.2.0-canary-fa3feba6-20250623(react@19.2.0-canary-fa3feba6-20250623): + react-dom@19.2.0-canary-cee7939b-20250625(react@19.2.0-canary-cee7939b-20250625): dependencies: - react: 19.2.0-canary-fa3feba6-20250623 + react: 19.2.0-canary-cee7939b-20250625 scheduler: 0.27.0-canary-a7a11657-20250708 react-is@19.2.0-canary-a7a11657-20250708: {} - react-is@19.2.0-canary-fa3feba6-20250623: {} + react-is@19.2.0-canary-cee7939b-20250625: {} react-lifecycles-compat@3.0.4: {} - react-number-format@5.4.0(react-dom@19.2.0-canary-fa3feba6-20250623(react@19.2.0-canary-fa3feba6-20250623))(react@19.2.0-canary-fa3feba6-20250623): + react-number-format@5.4.0(react-dom@19.2.0-canary-cee7939b-20250625(react@19.2.0-canary-cee7939b-20250625))(react@19.2.0-canary-cee7939b-20250625): dependencies: prop-types: 15.8.1 - react: 19.2.0-canary-fa3feba6-20250623 - react-dom: 19.2.0-canary-fa3feba6-20250623(react@19.2.0-canary-fa3feba6-20250623) + react: 19.2.0-canary-cee7939b-20250625 + react-dom: 19.2.0-canary-cee7939b-20250625(react@19.2.0-canary-cee7939b-20250625) react-refresh@0.12.0: {} - react-remove-scroll-bar@2.3.6(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623): + react-remove-scroll-bar@2.3.6(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625): dependencies: - react: 19.2.0-canary-fa3feba6-20250623 - react-style-singleton: 2.2.1(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623) + react: 19.2.0-canary-cee7939b-20250625 + react-style-singleton: 2.2.1(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625) tslib: 2.8.1 optionalDependencies: '@types/react': 19.1.1 - react-remove-scroll@2.5.10(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623): + react-remove-scroll@2.5.10(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625): dependencies: - react: 19.2.0-canary-fa3feba6-20250623 - react-remove-scroll-bar: 2.3.6(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623) - react-style-singleton: 2.2.1(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623) + react: 19.2.0-canary-cee7939b-20250625 + react-remove-scroll-bar: 2.3.6(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625) + react-style-singleton: 2.2.1(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625) tslib: 2.8.1 - use-callback-ref: 1.3.2(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623) - use-sidecar: 1.1.2(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623) + use-callback-ref: 1.3.2(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625) + use-sidecar: 1.1.2(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625) optionalDependencies: '@types/react': 19.1.1 @@ -32313,17 +32319,17 @@ snapshots: react: 19.2.0-canary-a7a11657-20250708 react-is: 19.2.0-canary-a7a11657-20250708 - react-ssr-prepass@1.0.8(react-is@19.2.0-canary-fa3feba6-20250623)(react@19.2.0-canary-a7a11657-20250708): + react-ssr-prepass@1.0.8(react-is@19.2.0-canary-cee7939b-20250625)(react@19.2.0-canary-a7a11657-20250708): dependencies: object-is: 1.0.2 react: 19.2.0-canary-a7a11657-20250708 - react-is: 19.2.0-canary-fa3feba6-20250623 + react-is: 19.2.0-canary-cee7939b-20250625 - react-style-singleton@2.2.1(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623): + react-style-singleton@2.2.1(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625): dependencies: get-nonce: 1.0.1 invariant: 2.2.4 - react: 19.2.0-canary-fa3feba6-20250623 + react: 19.2.0-canary-cee7939b-20250625 tslib: 2.8.1 optionalDependencies: '@types/react': 19.1.1 @@ -32335,12 +32341,12 @@ snapshots: react-shallow-renderer: 16.15.0(react@19.2.0-canary-a7a11657-20250708) scheduler: 0.27.0-canary-a7a11657-20250708 - react-textarea-autosize@8.5.3(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623): + react-textarea-autosize@8.5.3(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625): dependencies: '@babel/runtime': 7.27.0 - react: 19.2.0-canary-fa3feba6-20250623 - use-composed-ref: 1.3.0(react@19.2.0-canary-fa3feba6-20250623) - use-latest: 1.2.1(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623) + react: 19.2.0-canary-cee7939b-20250625 + use-composed-ref: 1.3.0(react@19.2.0-canary-cee7939b-20250625) + use-latest: 1.2.1(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625) transitivePeerDependencies: - '@types/react' @@ -32359,7 +32365,7 @@ snapshots: react@19.2.0-canary-a7a11657-20250708: {} - react@19.2.0-canary-fa3feba6-20250623: {} + react@19.2.0-canary-cee7939b-20250625: {} read-cache@1.0.0: dependencies: @@ -33078,6 +33084,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.1.4 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} sass-loader@15.0.0(@rspack/core@1.4.5(@swc/helpers@0.5.15))(sass@1.77.8)(webpack@5.98.0(@swc/core@1.11.24(@swc/helpers@0.5.15))(esbuild@0.24.2)): @@ -34915,34 +34923,34 @@ snapshots: punycode: 1.4.1 qs: 6.13.1 - use-callback-ref@1.3.2(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623): + use-callback-ref@1.3.2(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625): dependencies: - react: 19.2.0-canary-fa3feba6-20250623 + react: 19.2.0-canary-cee7939b-20250625 tslib: 2.8.1 optionalDependencies: '@types/react': 19.1.1 - use-composed-ref@1.3.0(react@19.2.0-canary-fa3feba6-20250623): + use-composed-ref@1.3.0(react@19.2.0-canary-cee7939b-20250625): dependencies: - react: 19.2.0-canary-fa3feba6-20250623 + react: 19.2.0-canary-cee7939b-20250625 - use-isomorphic-layout-effect@1.1.2(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623): + use-isomorphic-layout-effect@1.1.2(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625): dependencies: - react: 19.2.0-canary-fa3feba6-20250623 + react: 19.2.0-canary-cee7939b-20250625 optionalDependencies: '@types/react': 19.1.1 - use-latest@1.2.1(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623): + use-latest@1.2.1(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625): dependencies: - react: 19.2.0-canary-fa3feba6-20250623 - use-isomorphic-layout-effect: 1.1.2(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623) + react: 19.2.0-canary-cee7939b-20250625 + use-isomorphic-layout-effect: 1.1.2(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625) optionalDependencies: '@types/react': 19.1.1 - use-sidecar@1.1.2(@types/react@19.1.1)(react@19.2.0-canary-fa3feba6-20250623): + use-sidecar@1.1.2(@types/react@19.1.1)(react@19.2.0-canary-cee7939b-20250625): dependencies: detect-node-es: 1.1.0 - react: 19.2.0-canary-fa3feba6-20250623 + react: 19.2.0-canary-cee7939b-20250625 tslib: 2.8.1 optionalDependencies: '@types/react': 19.1.1 diff --git a/test/development/browser-logs/browser-logs.test.ts b/test/development/browser-logs/browser-logs.test.ts new file mode 100644 index 0000000000000..2784e650c922b --- /dev/null +++ b/test/development/browser-logs/browser-logs.test.ts @@ -0,0 +1,387 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'e2e-utils' +import webdriver from 'next-webdriver' +import { join } from 'path' +import stripAnsi from 'strip-ansi' +import { retry } from 'next-test-utils' + +const bundlerName = process.env.IS_TURBOPACK_TEST ? 'Turbopack' : 'Webpack' + +function setupLogCapture() { + const logs: string[] = [] + const originalStdout = process.stdout.write + const originalStderr = process.stderr.write + + const capture = (chunk: any) => { + logs.push(stripAnsi(chunk.toString())) + return true + } + + process.stdout.write = function (chunk: any) { + capture(chunk) + return originalStdout.call(this, chunk) + } + + process.stderr.write = function (chunk: any) { + capture(chunk) + return originalStderr.call(this, chunk) + } + + const restore = () => { + process.stdout.write = originalStdout + process.stderr.write = originalStderr + } + + const clearLogs = () => { + logs.length = 0 + } + + return { logs, restore, clearLogs } +} + +describe(`Terminal Logging (${bundlerName})`, () => { + describe('Pages Router', () => { + let next: NextInstance + let logs: string[] = [] + let logCapture: ReturnType + let browser = null + + beforeAll(async () => { + logCapture = setupLogCapture() + logs = logCapture.logs + + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'fixtures/pages')), + 'next.config.js': ` + module.exports = { + experimental: { + browserDebugInfoInTerminal: true + } + } + `, + }, + }) + }) + + afterAll(async () => { + logCapture.restore() + await next.destroy() + }) + + beforeEach(() => { + logCapture.clearLogs() + }) + + afterEach(async () => { + if (browser) { + await browser.close() + browser = null + } + }) + + it('should forward client component logs', async () => { + browser = await webdriver(next.url, '/pages-client-log') + await browser.waitForElementByCss('#log-button') + await browser.elementByCss('#log-button').click() + + await retry(() => { + const logOutput = logs.join('') + expect(logOutput).toContain( + '[browser] Log from pages router client component' + ) + }) + }) + + it('should handle circular references safely', async () => { + browser = await webdriver(next.url, '/circular-refs') + await browser.waitForElementByCss('#circular-button') + await browser.elementByCss('#circular-button').click() + + await retry(() => { + const logOutput = logs.join('\n') + expect(logOutput).toContain('[browser] Circular object:') + expect(logOutput).toContain('[Circular]') + }) + }) + + it('should respect default depth limit', async () => { + browser = await webdriver(next.url, '/deep-objects') + await browser.waitForElementByCss('#deep-button') + await browser.elementByCss('#deep-button').click() + + await retry(() => { + const logOutput = logs.join('\n') + expect(logOutput).toContain('[browser] Deep object: {') + expect(logOutput).toContain('level1: {') + expect(logOutput).toContain('level2: { level3: { level4: { level5:') + expect(logOutput).toContain("'[Object]'") + }) + }) + + it('should show source-mapped errors in pages router', async () => { + browser = await webdriver(next.url, '/pages-client-error') + await browser.waitForElementByCss('#error-button') + + logCapture.clearLogs() + + await browser.elementByCss('#error-button').click() + + await retry(() => { + const logOutput = logs.join('\n') + const browserErrorPattern = + /\[browser\] Uncaught Error: Client error in pages router\n\s+at throwClientError \(pages\/pages-client-error\.js:2:\d+\)\n\s+at callClientError \(pages\/pages-client-error\.js:6:\d+\)/ + expect(logOutput).toMatch(browserErrorPattern) + }) + }) + + it('should show source-mapped errors for server errors from pages router ', async () => { + const outputIndex = logs.length + + browser = await webdriver(next.url, '/pages-server-error') + + await retry(() => { + const newLogs = logs.slice(outputIndex).join('\n') + + const browserErrorPattern = + /\[browser\] Uncaught Error: Server error in pages router\n\s+at throwPagesServerError \(pages\/pages-server-error\.js:2:\d+\)\n\s+at callPagesServerError \(pages\/pages-server-error\.js:6:\d+\)/ + expect(newLogs).toMatch(browserErrorPattern) + }) + }) + }) + + describe('App Router - Server Components', () => { + let next: NextInstance + let logs: string[] = [] + let logCapture: ReturnType + + beforeAll(async () => { + logCapture = setupLogCapture() + logs = logCapture.logs + + next = await createNext({ + files: { + app: new FileRef(join(__dirname, 'fixtures/app')), + 'next.config.js': ` + module.exports = { + experimental: { + browserDebugInfoInTerminal: true + } + } + `, + }, + }) + }) + + afterAll(async () => { + logCapture.restore() + await next.destroy() + }) + + beforeEach(() => { + logCapture.clearLogs() + }) + + it('should not re-log server component logs', async () => { + const outputIndex = logs.length + await next.render('/server-log') + + await retry(() => { + const newLogs = logs.slice(outputIndex).join('') + expect(newLogs).toContain('Server component console.log') + }, 2000) + + const newLogs = logs.slice(outputIndex).join('') + + expect(newLogs).not.toContain('[browser] Server component console.log') + expect(newLogs).not.toContain('[browser] Server component console.error') + }) + + it('should show source-mapped errors for server components', async () => { + const outputIndex = logs.length + + const browser = await webdriver(next.url, '/server-error') + + await retry(() => { + const newLogs = logs.slice(outputIndex).join('\n') + + const browserErrorPattern = + /\[browser\] Uncaught Error: Server component error in app router\n\s+at throwServerError \(app\/server-error\/page\.js:2:\d+\)\n\s+at callServerError \(app\/server-error\/page\.js:6:\d+\)\n\s+at ServerErrorPage \(app\/server-error\/page\.js:10:\d+\)/ + expect(newLogs).toMatch(browserErrorPattern) + }) + + await browser.close() + }) + }) + + describe('App Router - Client Components', () => { + let next: NextInstance + let logs: string[] = [] + let logCapture: ReturnType + + beforeAll(async () => { + logCapture = setupLogCapture() + logs = logCapture.logs + + next = await createNext({ + files: { + app: new FileRef(join(__dirname, 'fixtures/app')), + 'next.config.js': ` + module.exports = { + experimental: { + browserDebugInfoInTerminal: true + } + } + `, + }, + }) + }) + + afterAll(async () => { + logCapture.restore() + await next.destroy() + }) + + beforeEach(() => { + logCapture.clearLogs() + }) + + it('should forward client component logs in app router', async () => { + const browser = await webdriver(next.url, '/client-log') + await browser.waitForElementByCss('#log-button') + await browser.elementByCss('#log-button').click() + + await retry(() => { + const logOutput = logs.join('') + expect(logOutput).toContain( + '[browser] Client component log from app router' + ) + }) + + await browser.close() + }) + + it('should show source-mapped errors for client components', async () => { + const browser = await webdriver(next.url, '/client-error') + await browser.waitForElementByCss('#error-button') + + logCapture.clearLogs() + + await browser.elementByCss('#error-button').click() + + await retry(() => { + const logOutput = logs.join('\n') + const browserErrorPattern = + /\[browser\] Uncaught Error: Client component error in app router\n\s+at throwError \(app\/client-error\/page\.js:4:\d+\)\n\s+at callError \(app\/client-error\/page\.js:8:\d+\)/ + expect(logOutput).toMatch(browserErrorPattern) + }) + + await browser.close() + }) + }) + + describe('App Router - Edge Runtime', () => { + let next: NextInstance + let logs: string[] = [] + let logCapture: ReturnType + + beforeAll(async () => { + logCapture = setupLogCapture() + logs = logCapture.logs + + next = await createNext({ + files: { + app: new FileRef(join(__dirname, 'fixtures/app')), + 'next.config.js': ` + module.exports = { + experimental: { + browserDebugInfoInTerminal: true + } + } + `, + }, + }) + }) + + afterAll(async () => { + logCapture.restore() + await next.destroy() + }) + + beforeEach(() => { + logCapture.clearLogs() + }) + + it('should handle edge runtime errors with source mapping', async () => { + const browser = await webdriver(next.url, '/edge-deep-stack') + + await retry(() => { + const logOutput = logs.join('\n') + + const browserErrorPattern = + /\[browser\] Uncaught Error: Deep stack error during render\n\s+at functionA \(app\/edge-deep-stack\/page\.js:6:\d+\)\n\s+at functionB \(app\/edge-deep-stack\/page\.js:10:\d+\)\n\s+at functionC \(app\/edge-deep-stack\/page\.js:14:\d+\)\n\s+at EdgeDeepStackPage \(app\/edge-deep-stack\/page\.js:18:\d+\)/ + expect(logOutput).toMatch(browserErrorPattern) + }) + + await browser.close() + }) + }) + + describe('Configuration Options', () => { + describe('showSourceLocation disabled', () => { + let next: NextInstance + let logs: string[] = [] + let logCapture: ReturnType + let browser = null + + beforeAll(async () => { + logCapture = setupLogCapture() + logs = logCapture.logs + + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'fixtures/pages')), + 'next.config.js': ` + module.exports = { + experimental: { + browserDebugInfoInTerminal: { + showSourceLocation: false + } + } + } + `, + }, + }) + }) + + afterAll(async () => { + logCapture.restore() + await next.destroy() + }) + + beforeEach(() => { + logCapture.clearLogs() + }) + + afterEach(async () => { + if (browser) { + await browser.close() + browser = null + } + }) + + it('should omit source location when disabled', async () => { + browser = await webdriver(next.url, '/basic-logs') + + await browser.waitForElementByCss('#log-button') + await browser.elementByCss('#log-button').click() + + await retry(() => { + const logOutput = logs.join('') + expect(logOutput).toContain('[browser] Hello from browser') + expect(logOutput).not.toMatch(/\([^)]+basic-logs\.[jt]sx?:\d+:\d+\)/) + }) + }) + }) + }) +}) diff --git a/test/development/browser-logs/fixtures/app/client-error/page.js b/test/development/browser-logs/fixtures/app/client-error/page.js new file mode 100644 index 0000000000000..d6a0cadf7e964 --- /dev/null +++ b/test/development/browser-logs/fixtures/app/client-error/page.js @@ -0,0 +1,24 @@ +'use client' + +function throwError() { + throw new Error('Client component error in app router') +} + +function callError() { + throwError() +} + +export default function ClientErrorPage() { + return ( +
+ +
+ ) +} diff --git a/test/development/browser-logs/fixtures/app/client-log/page.js b/test/development/browser-logs/fixtures/app/client-log/page.js new file mode 100644 index 0000000000000..d25bb42bdf94c --- /dev/null +++ b/test/development/browser-logs/fixtures/app/client-log/page.js @@ -0,0 +1,16 @@ +'use client' + +export default function ClientLogPage() { + return ( +
+ +
+ ) +} diff --git a/test/development/browser-logs/fixtures/app/edge-deep-stack/page.js b/test/development/browser-logs/fixtures/app/edge-deep-stack/page.js new file mode 100644 index 0000000000000..008dcee2ddc15 --- /dev/null +++ b/test/development/browser-logs/fixtures/app/edge-deep-stack/page.js @@ -0,0 +1,21 @@ +'use client' + +export const runtime = 'edge' + +function functionA() { + throw new Error('Deep stack error during render') +} + +function functionB() { + functionA() +} + +function functionC() { + functionB() +} + +export default function EdgeDeepStackPage() { + functionC() + + return
+} diff --git a/test/development/browser-logs/fixtures/app/layout.js b/test/development/browser-logs/fixtures/app/layout.js new file mode 100644 index 0000000000000..4ee00a218505a --- /dev/null +++ b/test/development/browser-logs/fixtures/app/layout.js @@ -0,0 +1,7 @@ +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/development/browser-logs/fixtures/app/server-error/page.js b/test/development/browser-logs/fixtures/app/server-error/page.js new file mode 100644 index 0000000000000..0421db5586f10 --- /dev/null +++ b/test/development/browser-logs/fixtures/app/server-error/page.js @@ -0,0 +1,13 @@ +function throwServerError() { + throw new Error('Server component error in app router') +} + +function callServerError() { + throwServerError() +} + +export default function ServerErrorPage() { + callServerError() + + return
+} diff --git a/test/development/browser-logs/fixtures/app/server-log/page.js b/test/development/browser-logs/fixtures/app/server-log/page.js new file mode 100644 index 0000000000000..0d54ff70864d3 --- /dev/null +++ b/test/development/browser-logs/fixtures/app/server-log/page.js @@ -0,0 +1,6 @@ +export default function ServerLogPage() { + console.log('Server component console.log') + console.error('Server component console.error') + + return
+} diff --git a/test/development/browser-logs/fixtures/pages/basic-logs.js b/test/development/browser-logs/fixtures/pages/basic-logs.js new file mode 100644 index 0000000000000..49fb86d89283a --- /dev/null +++ b/test/development/browser-logs/fixtures/pages/basic-logs.js @@ -0,0 +1,30 @@ +export default function BasicLogsPage() { + return ( +
+ + + +
+ ) +} diff --git a/test/development/browser-logs/fixtures/pages/circular-refs.js b/test/development/browser-logs/fixtures/pages/circular-refs.js new file mode 100644 index 0000000000000..780be4b0b6ae9 --- /dev/null +++ b/test/development/browser-logs/fixtures/pages/circular-refs.js @@ -0,0 +1,16 @@ +export default function CircularRefsPage() { + return ( +
+ +
+ ) +} diff --git a/test/development/browser-logs/fixtures/pages/deep-objects.js b/test/development/browser-logs/fixtures/pages/deep-objects.js new file mode 100644 index 0000000000000..99f8d6366e19d --- /dev/null +++ b/test/development/browser-logs/fixtures/pages/deep-objects.js @@ -0,0 +1,29 @@ +export default function DeepObjectsPage() { + return ( +
+ +
+ ) +} diff --git a/test/development/browser-logs/fixtures/pages/pages-client-error.js b/test/development/browser-logs/fixtures/pages/pages-client-error.js new file mode 100644 index 0000000000000..811c8915442da --- /dev/null +++ b/test/development/browser-logs/fixtures/pages/pages-client-error.js @@ -0,0 +1,20 @@ +function throwClientError() { + throw new Error('Client error in pages router') +} + +function callClientError() { + throwClientError() +} + +export default function PagesClientError() { + return ( +
+ +
+ ) +} diff --git a/test/development/browser-logs/fixtures/pages/pages-client-log.js b/test/development/browser-logs/fixtures/pages/pages-client-log.js new file mode 100644 index 0000000000000..4d63ffcafbfe3 --- /dev/null +++ b/test/development/browser-logs/fixtures/pages/pages-client-log.js @@ -0,0 +1,22 @@ +export default function PagesClientLog() { + return ( +
+ + +
+ ) +} diff --git a/test/development/browser-logs/fixtures/pages/pages-server-error.js b/test/development/browser-logs/fixtures/pages/pages-server-error.js new file mode 100644 index 0000000000000..49d627f744c47 --- /dev/null +++ b/test/development/browser-logs/fixtures/pages/pages-server-error.js @@ -0,0 +1,19 @@ +function throwPagesServerError() { + throw new Error('Server error in pages router') +} + +function callPagesServerError() { + throwPagesServerError() +} + +export async function getServerSideProps() { + callPagesServerError() + + return { + props: {}, + } +} + +export default function PagesServerError() { + return
+}