refactor: remove ClientFileLogger, consolidate file logging into handleLog (#92329)
## Background: three sources of file log entries
Browser `console.*` calls in Next.js dev mode are forwarded to the
server over a WebSocket. Before this PR, three independent mechanisms
wrote to the `.next/logs/next-development.log` file:
1. **`ClientFileLogger`** (client → `client-file-logs` WS event →
`handleClientFileLogs`) — a class in `forward-logs.ts` that intercepted
every `console.*` call unconditionally, serialized args into
message-only strings (no stack traces, no source mapping), and sent them
to the server over a separate `client-file-logs` WebSocket event. The
server's `handleClientFileLogs` wrote them directly to the file logger
as `Browser` entries.
2. **`handleLog` direct file writes** (client → `browser-logs` WS event
→ `handleLog` → `fileLogger`) — the primary path. The browser's patched
console methods serialize args into `ClientLogEntry` objects, batch
them, and send them as `browser-logs` WebSocket events. On the server,
`handleLog` in `receive-logs.ts` deserializes the args, source-maps
stack traces, and forwards to the terminal. As a side effect, it also
wrote directly to the file logger.
3. **`console-file.tsx` interception** (server-side `console.*` →
`fileLogger.logServer`) — a server-side environment extension that
patches the server process's own `console.*` methods. When `handleLog`
calls `forwardConsole.error()` to print browser logs to the terminal,
this interception captures that output and writes it as a `Server`
entry. The `[browser]` text visible in these entries' messages is the
terminal prefix leaking into the file log.
## Problems with the old system
### Duplicate entries
On the client side, `createLogEntry` gated the `browser-logs` send
behind `isTerminalLoggingEnabled`:
```javascript
if (!isTerminalLoggingEnabled) {
return // skip browser-logs WebSocket send
}
```
But `clientFileLogger.log()` was called **before** this check,
unconditionally. So `ClientFileLogger` always produced file log entries
regardless of config.
Meanwhile, `handleLog` was gated by `browserToTerminalConfig` in both
hot reloaders. Because `experimental.browserDebugInfoInTerminal`
defaults to `'warn'`, this gate was open by default — `handleLog` ran
for errors and warnings even in code without explicit
`logging.browserToTerminal` config, like this test. But `console.log`
was filtered out at the `'warn'` level, so only `ClientFileLogger` wrote
those.
The net effect: errors and warnings were written to the file by **both**
`ClientFileLogger` (message-only) and `handleLog` (with source-mapped
stacks), producing duplicates. `console.log` was written only by
`ClientFileLogger`.
### Mislabeled source
The direct file writes inside `handleLog` had a bug: the
`any-logged-error` and `formatted-error` code paths hardcoded
`fileLogger.logBrowser()` instead of checking `isServerLog`. The
`console` kind already checked `isServerLog` correctly. This
inconsistency meant that when a server-decorated error (one with the
`Symbol.for('NextjsError')` property set by `decorateServerError`) was
logged via `console.error(serverError)` in the browser, the `sourceType`
would be legitimately set to `'server'`, but the file log entry would
still be labeled `Browser`.
This bug didn't manifest in this test because the test calls
`console.error('string')` with no Error objects — `forwardErrorLog`
never finds an Error to call `getErrorSource` on, so `sourceType`
remains `undefined` and `isServerLog` is `false`. The hardcoded
`logBrowser` happened to produce the correct label by coincidence.
### The result
With the default config, a single `console.error('string')` in the
browser produced **three** file log entries:
1. `Browser ERROR` (message-only) — from `ClientFileLogger`
2. `Browser ERROR` (with stack) — from `handleLog`'s direct file write
(hardcoded `logBrowser`)
3. `Server ERROR` (with stack + `[browser]` prefix) — from
`console-file.tsx` intercepting `handleLog`'s `forwardConsole.error()`
terminal output
## What this PR does
**Removes `ClientFileLogger` and the `client-file-logs` channel
entirely.** File logging now happens via `handleLog`'s direct writes and
`console-file.tsx` interception.
**Decouples the client-side send from terminal logging.** The old
`createLogEntry` gated the `browser-logs` send on
`isTerminalLoggingEnabled`. The new code gates on `shouldForwardLogs`:
```javascript
const shouldForwardLogs = isTerminalLoggingEnabled || !!process.env.__NEXT_MCP_SERVER
```
This is the key mechanism that makes consolidation possible —
`browser-logs` events now fire when either terminal logging or the MCP
server (which controls file logging) is enabled, so `handleLog` always
receives the entries it needs.
**Decouples server-side file logging from terminal output.** `handleLog`
is no longer gated by `browserToTerminalConfig` at the hot-reloader
level — it always runs. Inside `handleLog`, terminal output and file
logging are controlled independently: `shouldShowEntry(entry, config)`
gates terminal output, `fileLogger.isEnabled()` gates file writes.
**Fixes the `isServerLog` labeling bug.** All entry kinds (`console`,
`any-logged-error`, `formatted-error`) now check `isServerLog` and call
`logServer()`/`logBrowser()` accordingly.
## Snapshot diff explanation
### Old snapshot (7 entries)
```
1. Browser LOG — "Complex circular object: ..." ← ClientFileLogger
2. Browser ERROR — "message" ← ClientFileLogger (message-only)
3. Browser WARN — "message" ← ClientFileLogger (message-only)
4. Server ERROR — "[browser] message + stack + loc" ← console-file.tsx (intercepting forwardConsole.error)
5. Browser ERROR — "message + stack + loc" ← handleLog any-logged-error (hardcoded logBrowser)
6. Server WARN — "[browser] message + loc" ← console-file.tsx (intercepting forwardConsole.warn)
7. Browser WARN — "message" ← handleLog console kind (logBrowser, isServerLog=false)
```
### New snapshot (5 entries)
```
1. Browser LOG — "Complex circular object: ..." ← handleLog console kind (logBrowser)
2. Browser ERROR — "message + stack + loc" ← handleLog any-logged-error (logBrowser)
3. Server ERROR — "[browser] message + stack + loc" ← console-file.tsx (intercepting forwardConsole.error)
4. Browser WARN — "message" ← handleLog console kind (logBrowser)
5. Server WARN — "[browser] message + loc" ← console-file.tsx (intercepting forwardConsole.warn)
```
### What changed
With `ClientFileLogger` removed, each `console.*` call now produces one
`Browser` entry from `handleLog` instead of two (ClientFileLogger
message-only + handleLog with stack). Per call:
- **`console.log`**: old `#1` (ClientFileLogger) → new `#1` (handleLog).
Previously only `ClientFileLogger` wrote this — `handleLog` skipped it
because `log` level was below the `'warn'` terminal threshold. Now
`handleLog` writes file logs for all entry kinds before the `shouldShow`
check.
- **`console.error`**: old `#2` (ClientFileLogger duplicate) removed.
Old `#5` (handleLog) → new `#2`, now with cleaner formatting.
- **`console.warn`**: old `#3` (ClientFileLogger duplicate) removed. Old
`#7` (handleLog) → new `#4`.
- **`console-file.tsx` entries unchanged**: old `#4`/`#6` → new
`#3`/`#5`. These are artifacts of `console-file.tsx` intercepting the
server process's terminal output, not from `handleLog` directly.