Skip to content

feat(plugin-vite): render Vite build and dev-server output in the terminal multi-logger - #4390

Open
claude[bot] wants to merge 19 commits into
mainfrom
feat/vite-multi-logger
Open

claude[bot] wants to merge 19 commits into
mainfrom
feat/vite-multi-logger

Conversation

@claude

@claude claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Requested by Erick Zhao · Slack thread

Stacked on #4389 (merged).

Before: With the Vite plugin, forge start prints the two Vite watch subprocesses' stdout/stderr straight to the console, interleaved with the Electron app's output and with each other; the renderer dev servers' request/HMR/error logs go to the console through Vite's default logger; nothing tells you at a glance which target is rebuilding, succeeded or failed.

After: Each build target (Main (src/main.ts), Preload (src/preload.ts)) and each renderer dev server (Renderer (main_window)) gets its own tab in the terminal UI next to core's App tab, with a live status (spinner while building, ✔ 11ms on success, ✖ 1 on error) that updates on every rebuild; Vite's HMR and error output lands in the renderer's tab and marks it warning/error until the next HMR cycle. In a non-TTY or CI the same output is written as [Main (src/main.ts)] … lines, and a failing first build still shows the Vite error text.

This wires the Vite plugin into the shared multi-logger the same way the webpack plugin is, so the Vite output shows up as tabs with statuses instead of raw console writes.

How

  • The plugin calls ensureSharedLogger() and only creates tabs on it; core owns the logger (start/stop, the App tab, r).
  • Watch subprocesses: logger.attachProcess(child, tab.name) replaces the direct process.stdout/stderr forwarding. The stderr copy used to explain a worker that dies before its first build is kept as a second data listener.
  • Richer IPC: subprocess-worker.ts now sends build-start, build-done { durationMs } and build-error { message } from the Rollup watcher's START/BUNDLE_END/ERROR events (typed in a new worker-messages.ts), alongside the existing first-build-done/first-build-error/reload-renderers. statusFromWorkerMessage maps them onto the tab status; first-build-done only fills in success when BUNDLE_END has not already reported the duration.
  • Renderer dev servers: createTabLogger(tab, logLevel) is passed as Vite's customLogger (a user-provided customLogger still wins). It formats timestamped lines like Vite (<time> [vite] (client) …), honors logLevel, dedupes warnOnce, counts warnings/errors in the status and returns to success when Vite starts a fresh cycle (info with clear: true, i.e. an HMR update or page reload). The server URLs are logged into the tab once it is listening.
  • Tab names are numbered (#2) when two targets share an entry. Docs: a "Compiler output" section in docs/config/plugins/vite.mdx.
  • Tests: tab naming, status mapping, the custom logger, the worker's watch-mode IPC (first build with duration, failing build, real worker), and that the plugin's postStart leaves the app's stdio to core.

Open questions

  • Tab order follows the plugin's task order (renderers first, since main needs their ports): Renderer, Main, Preload. Webpack lists Main Process first; happy to pre-create the build tabs so main comes first if that consistency matters.
  • The "back to success on clear: true" heuristic for renderer tabs is a judgement call: it is what Vite uses to mark a new HMR/reload cycle, and a still-broken transform re-marks the tab error right after, but there is no explicit "resolved" signal from Vite.
  • Pre-existing, left alone: the worker's closeBundle hook sends reload-renderers even when the build errored (unlike pluginHotRestart, which guards on the error argument). Worth a follow-up.
  • The worker keeps logLevel: 'silent' and prints its own one-liners, so the tab shows target built src/main.js per rebuild rather than Vite's full asset table. Switching to Vite's own build output in the tab would be a small follow-up if preferred.

Smoke-tested through the real CLI on a template-derived fixture with a stubbed electron, both under a PTY (ink UI, tab switching, quit summary) and piped/CI (plain lines), including a broken main entry.

🤖 Generated with Claude Code

https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ

…gger

Replace the express + xterm.js web UI with an ink-based terminal renderer.
Tabs, statuses and child-process output render right in the console when
attached to a TTY, with a tag-prefixed plain fallback for CI and pipes.
Everything logged before start() is buffered, and stop() flushes it as
plain text if the UI never rendered.

Enables "jsx": "react-jsx" in the shared tsconfig so the package can use
JSX, and teaches vitest/oxlint/knip about .tsx sources.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…m it

Spawn Electron with stdout/stderr piped instead of inherited. A postStart
hook that starts reading a stream claims it; anything still unclaimed once
the hooks have run is forwarded to Forge's own stdio, so behaviour is
unchanged unless a plugin opts in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
Swap @electron-forge/web-multi-logger for @electron-forge/multi-logger.
Compiler output is buffered during preStart and rendered from postStart,
once listr is done, with the Electron app's own output attached as a tab
(reused across restarts). Tabs report building/success/warning/error
status with counts and durations, and `r` restarts Electron since ink's
raw mode hides the `rs` line from core.

`loggerPort` is now deprecated and ignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
BREAKING CHANGE: loggerPort no longer exists; compiler output renders in the terminal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…e raw mode after restart

The tab bar and footer are `height={1}` Boxes with ink's default
`flexShrink: 1`. The body was sized in logical lines, so once lines wrapped
(long webpack output, or the `[Tab] ` prefixes of the merged view) the body
overflowed the fixed-height root and Yoga shrank the header to nothing,
leaving a blank first row and desyncing ink's line diff. The chrome now has
`flexShrink={0}` and the body keeps only the tail that fits in its screen
rows, measured with the same `wrap-ansi` settings ink uses.

The tab bar also relied on flex clipping, which cut the last chip mid-word
and hid `All` entirely at 120 columns. It is now measured with
`string-width` and degrades in steps: status details go first, then the
title, and finally the chips wrap onto more rows (the body shrinks to
match), so every chip, `All` included, is always visible.

Also:
- rename the Electron tab to `App` (and make it the default name for
  `attachProcess`); add `initialTab` so the host can pick the first view
  (a tab name or `'all'`);
- switch to a tab whose status turns to `error`, at most once per
  `errorSwitchDebounceMs` (default 15s), never re-triggering for a tab
  that stays in error;
- put stdin back into raw mode when an attached child exits: a child that
  inherited the tty (Electron is a Node embedder) restores cooked mode on
  exit while `stdin.isRaw` still reads `true`, so ink never re-asserted it
  and keys were echoed instead of handled after a restart;
- do not colour the status glyph on the active (inverse) chip, where the
  colour became a background cell.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
A terminal can only show one UI, so a host and the plugins running inside it
need to share a logger rather than each creating their own. `ensureSharedLogger`
returns the process-wide instance (creating it on first use), `getSharedLogger`
peeks at it and `resetSharedLogger` forgets it for tests. The instance lives
under `Symbol.for('@electron-forge/multi-logger')` on `globalThis`, so duplicate
copies of the package in one node_modules tree still share it. Options passed to
a later `ensureSharedLogger` call are merged additively through the new
`Logger#extendOptions`: extra keys, and a title / initial tab if none is set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
`@electron-forge/core` now owns the tabbed terminal UI: every interactive
`forge start` creates the process-wide shared logger before any hook runs,
attaches the Electron app's stdout/stderr to its "App" tab (the default view)
on every spawn and restart, and starts it once the app is up. Plugins only add
tabs. While the UI is drawing, `r` restarts the app instead of typing `rs`, the
"Restarting Electron app" notice goes into the App tab rather than over the
alternate screen, and the UI is torn down when the app exits for good.
Non-interactive (programmatic) starts are unchanged: no UI is created and the
app's output is forwarded to the parent's stdio as before.

BREAKING CHANGE: plugins must add tabs to the shared logger instead of creating their own; the app's stdio is attached to the App tab in interactive mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…minal multi-logger

During `electron-forge start` the Vite plugin now puts each `build` target
and each renderer dev server in a tab of the shared terminal UI instead of
writing straight to the console.

* Each watch subprocess (`Main (src/main.ts)`, `Preload (src/preload.ts)`,
  numbered when two targets share an entry) is attached to a tab with
  `attachProcess`, so its stdout/stderr land there. The worker now also
  sends `build-start`, `build-done` (with the watcher's duration) and
  `build-error` IPC messages from the Rollup watcher events, which the
  plugin maps onto the tab's status; `first-build-done`/`first-build-error`
  keep settling the "wait for the first build" promise. The stderr copy used
  for a worker that dies before its first build is kept.
* Each renderer dev server (`Renderer (<name>)`) gets a Vite `customLogger`
  that writes into its tab, formats timestamped lines like Vite does,
  honors the configured `logLevel`, counts warnings/errors in the status
  and returns to `success` when Vite starts a fresh cycle (`clear: true`).
  The dev server URLs are logged into the tab once it is listening. A
  renderer config that already sets `customLogger` keeps it.
* The logger itself is owned by core (`ensureSharedLogger()`); the plugin
  only creates tabs on it and does not start, stop or attach the app.

Tests cover tab naming, the status mapping, the custom logger, the worker's
watch-mode IPC messages (first build, duration, failing build) and that the
postStart hook leaves the app's stdio alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
@claude
claude Bot force-pushed the feat/vite-multi-logger branch from aa452ce to 746c9c0 Compare September 12, 2026 01:12
The watch-mode worker sends `build-error` before printing the error, and
stdio pipes are asynchronous on Windows, so the test killed the worker
before the printed error reached it and `stderr` came back empty. Re-check
the stop condition on every stderr chunk too and, for the failing build,
include the printed error in it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…s non-interactive

`start()` now owns the shared logger for every run, not only interactive
ones. Plugins add tabs unconditionally (webpack does so from its preStart
hook), and previously a non-interactive start (`process.stdin.isTTY` false,
as in CI, Docker or piped input, or a programmatic `api.start()`) never
started the logger, so the plugins' compiler output and watch-mode build
errors were buffered and silently dropped. Core now creates the logger with
`interactive: false` in that case, which forces plain mode, starts it once
the app is up so tabs are printed as `[Tab]`-prefixed lines, and stops it
when the app exits. A project without plugins prints nothing: an empty plain
logger is silent.

The app's stdio is only piped when there is an App tab to show it in
(interactive). Non-interactive starts spawn Electron with `stdio: 'inherit'`
again, exactly as before this branch, so the app keeps the real terminal fds
(`isTTY`, window size) and no output can be lost in a Node-level pipe. The
`forwardUnclaimedStream` helper that piped the streams back to our stdio is
gone with it.

Two follow-ups from review:

- `ensureSharedLogger()` replaces a singleton that has been stopped, so a
  second `api.start()` in the same process gets a live logger rather than one
  whose `start()` no-ops.
- `Logger.mode` now reports the renderer actually in use: it switches to
  `'plain'` when the interactive UI fails to load. `start()` decides whether
  to read `rs` from stdin after `logger.start()` has resolved, so the app can
  still be restarted from the keyboard after such a fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…iped stdio

A plugin's `startLogic` may hand back a child it spawned with inherited
stdio. Attaching it to the App tab was a no-op (both streams null) while
the terminal UI's alternate screen hid the app's real output. Core now
detects that case, skips attaching and forces the shared logger into plain
mode (new `Logger.forcePlain()`), so plugin tabs still print as `[Tab]`
lines and the app keeps the terminal.

Also stop claiming the Vite Plugin adds tabs to the terminal UI; only the
Webpack Plugin is wired to the shared logger in this change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
Base automatically changed from feat/console-multi-logger to next September 16, 2026 02:41
@github-actions github-actions Bot added the next label Sep 16, 2026
@erickzhao
erickzhao marked this pull request as ready for review September 16, 2026 06:48
@erickzhao
erickzhao requested a review from a team as a code owner September 16, 2026 06:48

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread packages/plugin/vite/src/VitePlugin.ts
Comment thread packages/plugin/vite/src/logging.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

The org-required Zizmor Audit workflow now gates `main` (it follows the
default branch), and main's ci.yml/gh-pages.yml never received the
cache-poisoning hardening 7.x got in #4200. Mirror that change so PRs
against main can go green again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
@claude

claude Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Working with Samuel Attard and Erick Zhao

zizmor Static Analysis is red here, but not because of this PR: it touches nothing under .github/. The org-required Zizmor Audit started gating main today when next was promoted, and main's ci.yml/gh-pages.yml never got the cache-poisoning hardening 7.x has from #4200, so every PR into main fails the same 4 findings. Fix is #4401; I've cherry-picked it here so this PR can go green (it no-ops once main has it).


Generated by Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, this run also examined and ruled out two other candidates: the new per-rebuild process.send() calls in subprocess-worker.ts's Rollup watcher event handler use the same unguarded process.send?.() pattern the pre-existing first-build-done/first-build-error messages already relied on, so the higher call frequency isn't a new failure mode; and a renderer dev-server startup failure (VitePlugin.ts:529) still propagates as a rejected Listr task rather than being silently swallowed, even though the tab visually stays at "building".

Extended reasoning...

This run reported one confirmed pre-existing bug (stderr cleared before being read into the error message in spawnViteBuildWatch's settle()) as an inline comment, so approval is off the table per the guidelines. Two prior rounds' inline threads (VitePlugin.ts:533 forcing tab status to success, logging.ts:123 status updates ignoring the log threshold) were resolved only by this bot's own session, which the instructions say is not independent sign-off, so I did not restate or treat them as settled. I additionally verified, by reading the diff directly, that the confirmed settle()/stderr bug is byte-for-byte identical to the base branch's version (just wrapped in the new logger/tab plumbing), confirming it's pre-existing rather than newly introduced. I also spot-checked the two other candidates the pipeline flagged and ruled out (unguarded process.send() on every rebuild, and a renderer tab getting stuck at "building" on a startup failure) against the code and found the ruling-out reasoning plausible: the send pattern is unchanged from what already existed for the first-build messages, and a startup failure's exception still propagates through the Listr task rather than being swallowed. Since new, non-redundant information exists (the ruled-out items) beyond what I posted in the prior two reviews, a brief informational note is warranted rather than staying fully silent, but decision cannot be "approve" while a verified finding is being posted inline.

Comment thread packages/plugin/vite/src/VitePlugin.ts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant