diff --git a/docs/docs/config.md b/docs/docs/config.md index 3a615851..189cb787 100644 --- a/docs/docs/config.md +++ b/docs/docs/config.md @@ -21,6 +21,7 @@ page-plugin settings. | `routing` | Enable file-based pages and select SPA or MPA | Not enabled until declared | | `conventions` | Enable all framework file conventions | `true` | | `dev` | Browser development server | Port `3000` | +| `logging` | Development logging, including browser-to-terminal forwarding | Browser errors forwarded | | `server` | Server runtime, build resolution, and development server | Base path `/__evjs`, dev port `3001` | | `transport` | Browser-to-server origin | Same origin | | `target` | Production Android and iOS compatibility target | Bundler default | @@ -129,6 +130,22 @@ A proxy rule accepts `context`, `target`, optional `pathRewrite`, client HTTPS. Select the Webpack adapter when the client dev server requires a custom key/certificate pair. +### `logging` + +`logging.browserToTerminal` follows the Next.js-compatible level contract and +only affects `ev dev` with the Utoopack adapter: + +| Value | Browser output forwarded to the terminal | +| --- | --- | +| `"error"` | Errors and unhandled rejections (default) | +| `"warn"` | Warnings plus errors | +| `true` | All standard console levels | +| `false` | Nothing | + +Set top-level `logging: false` to disable configurable logging. Essential CLI +lifecycle output and fatal diagnostics remain enabled. The Webpack adapter does +not currently implement browser log forwarding. + ### `server.dev` | Field | Type | Default | diff --git a/docs/docs/dev.md b/docs/docs/dev.md index 5b969112..60aaa1e8 100644 --- a/docs/docs/dev.md +++ b/docs/docs/dev.md @@ -42,6 +42,9 @@ the same project directory at a time; stop an existing `dev`, `prepare`, or import { defineConfig } from "@evjs/ev"; export default defineConfig({ + logging: { + browserToTerminal: "error", // Browser errors -> terminal (default) + }, dev: { port: 4000, }, @@ -172,6 +175,25 @@ evjs core does not reserve shortcut keys; installed plugins define the keys they use. Plugin authors can find the authoring contract in [Plugin Hooks](./plugin-hooks). +## Browser logs in the terminal + +The Utoopack development server forwards browser errors to the terminal by +default. Configure the Next.js-compatible forwarding level in `ev.config.ts`: + +```ts +export default defineConfig({ + logging: { + browserToTerminal: "warn", + }, +}); +``` + +Use `"error"` for errors only (the default), `"warn"` for warnings and errors, +`true` for all standard console levels, or `false` to disable forwarding. +Forwarded lines use a `[browser]` prefix and include source-mapped application +locations when available. This is development-only and is not a production log +collector. The Webpack adapter does not currently implement this option. + ## Test server paths directly The default server runtime prefix is `/__evjs`. Server-function, PPR, and RSC diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/config.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/config.md index df594176..cc4b0fd2 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/config.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/config.md @@ -19,6 +19,7 @@ export default defineConfig({ | `routing` | 启用文件页面并选择 SPA 或 MPA | 声明后才启用 | | `conventions` | 启用全部框架文件约定 | `true` | | `dev` | 浏览器开发服务器 | 端口 `3000` | +| `logging` | 开发日志,包括浏览器到终端转发 | 默认转发浏览器错误 | | `server` | 服务端运行时、构建解析与开发服务器 | 基础路径 `/__evjs`,开发端口 `3001` | | `transport` | 浏览器到服务端的来源 | 同源 | | `target` | 生产 Android 与 iOS 兼容目标 | 构建器默认值 | @@ -121,6 +122,20 @@ export default defineConfig({ 代理规则支持 `context`、`target`,以及可选 `pathRewrite`、`changeOrigin` 和 `secure`。默认 Utoopack 适配器支持布尔形式的客户端 HTTPS;需要自定义客户端证书时选择 Webpack 适配器。 +### `logging` + +`logging.browserToTerminal` 使用与 Next.js 兼容的级别契约,仅影响采用 +Utoopack 适配器的 `ev dev`: + +| 值 | 转发到终端的浏览器输出 | +| --- | --- | +| `"error"` | 错误与未处理的 Promise rejection(默认值) | +| `"warn"` | 警告与错误 | +| `true` | 全部标准 console 级别 | +| `false` | 不转发 | + +设置顶层 `logging: false` 可关闭可配置日志。必要的 CLI 生命周期输出与致命诊断仍会保留。Webpack 适配器目前尚未实现浏览器日志转发。 + ### `server.dev` | 字段 | 类型 | 默认值 | diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/dev.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/dev.md index 1a229129..cdbbbd55 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/dev.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/dev.md @@ -37,6 +37,9 @@ flowchart LR import { defineConfig } from "@evjs/ev"; export default defineConfig({ + logging: { + browserToTerminal: "error", // 浏览器错误 -> 终端(默认值) + }, dev: { port: 4000, }, @@ -150,6 +153,20 @@ ev dev --no-shortcuts evjs 核心不占用任何按键,具体快捷键由已安装插件定义。插件作者可以在[插件生命周期钩子](./plugin-hooks)查看契约。 +## 在终端查看浏览器日志 + +Utoopack 开发服务器默认会将浏览器错误转发到终端。在 `ev.config.ts` 中使用与 Next.js 兼容的选项配置转发级别: + +```ts +export default defineConfig({ + logging: { + browserToTerminal: "warn", + }, +}); +``` + +`"error"` 仅转发错误(默认值),`"warn"` 转发警告与错误,`true` 转发全部标准 console 级别,`false` 关闭转发。转发行以 `[browser]` 为前缀,并在可用时包含 source map 还原后的应用源码位置。该能力仅用于开发,并非生产日志采集器。Webpack 适配器目前尚未实现此选项。 + ## 直接测试服务端路径 默认服务端运行时前缀是 `/__evjs`。只有应用使用对应能力时才会创建服务端函数、PPR 与 RSC 路径。这个前缀不是服务端通配命名空间,因此无关客户端路由仍然可用。 diff --git a/packages/bundler-utoopack/src/adapter/config/create-config.ts b/packages/bundler-utoopack/src/adapter/config/create-config.ts index a11d31d6..3c161e14 100644 --- a/packages/bundler-utoopack/src/adapter/config/create-config.ts +++ b/packages/bundler-utoopack/src/adapter/config/create-config.ts @@ -20,7 +20,7 @@ import { resolveBuildOutputPaths, SERVER_FUNCTION_TRANSFORM_RUNTIME, } from "@evjs/ev/_internal/build"; -import type { ResolvedConfig } from "@evjs/ev/config"; +import type { BrowserToTerminal, ResolvedConfig } from "@evjs/ev/config"; import type { ConfigureBundlerContext, PluginHooks } from "@evjs/ev/plugin"; import { pageRoutePathToRegExp } from "@evjs/shared"; import type { BuildPlan } from "@evjs/shared/manifest"; @@ -41,6 +41,12 @@ const lessImplementation = require.resolve("less"); const lessLoader = require.resolve("less-loader"); const spaHistoryFallbackRuleIndexes = new WeakMap(); +type UtoopackDevServerConfigWithBrowserLogs = NonNullable< + ConfigComplete["devServer"] +> & { + browserToTerminal?: BrowserToTerminal; +}; + export function getSpaHistoryFallbackRuleIndex( config: ConfigComplete, ): number | undefined { @@ -118,6 +124,16 @@ export async function createUtoopackConfig( const outputPaths = resolveBuildOutputPaths(cwd, plan); await assertSafeBuildOutputPaths(cwd, outputPaths); + const devServerConfig: UtoopackDevServerConfigWithBrowserLogs = { + hot: true, + port: config.dev.port, + https: config.dev.https !== false, + proxy: devProxy, + ...(!isProduction + ? { browserToTerminal: config.logging.browserToTerminal } + : {}), + }; + const utoopackConfig: ConfigComplete = { mode, ...(isProduction && config.target !== undefined && clientEntries.length > 0 @@ -209,12 +225,7 @@ export async function createUtoopackConfig( : {}), // Dev server configuration - devServer: { - hot: true, - port: config.dev.port, - https: config.dev.https !== false, - proxy: devProxy, - }, + devServer: devServerConfig, }; const frameworkExpectation = snapshotUtoopackFrameworkExpectation(utoopackConfig); diff --git a/packages/bundler-utoopack/tests/adapter.test.ts b/packages/bundler-utoopack/tests/adapter.test.ts index 001834ea..c20ebca3 100644 --- a/packages/bundler-utoopack/tests/adapter.test.ts +++ b/packages/bundler-utoopack/tests/adapter.test.ts @@ -24,6 +24,7 @@ import type { ConfigComplete } from "@utoo/pack"; import { afterEach, describe, expect, it, vi } from "vitest"; import { withPageRoutingDefaults } from "../../ev/esm/_internal/build/discovery/convention-config.js"; import { createClientRuntime } from "../../ev/src/_internal/build/output/framework-runtime.js"; +import { createUtoopackConfig } from "../src/adapter/config/create-config.js"; import { utoopackAdapter, __testing as utoopackAdapterTesting, @@ -459,6 +460,33 @@ describe("utoopackAdapter output safety", () => { }); }); +describe("utoopackAdapter browser log forwarding config", () => { + it("maps the resolved level into development config only", async () => { + const cwd = await makeProject(); + const config = await resolveProjectConfig(cwd, { + logging: { browserToTerminal: "warn" }, + }); + const { plan } = await createBuildContext(config, cwd); + + const development = await createUtoopackConfig(config, plan, cwd, []); + expect(Reflect.get(development.devServer ?? {}, "browserToTerminal")).toBe( + "warn", + ); + + const productionPlan = structuredClone(plan); + productionPlan.mode = "production"; + const production = await createUtoopackConfig( + config, + productionPlan, + cwd, + [], + ); + expect(Object.hasOwn(production.devServer ?? {}, "browserToTerminal")).toBe( + false, + ); + }); +}); + describe("utoopackAdapter dev", () => { it("waits for readable stats until the session is aborted", async () => { const cwd = await makeProject(); diff --git a/packages/bundler-utoopack/tests/create-config.test.ts b/packages/bundler-utoopack/tests/create-config.test.ts index 0a940134..d8c449d3 100644 --- a/packages/bundler-utoopack/tests/create-config.test.ts +++ b/packages/bundler-utoopack/tests/create-config.test.ts @@ -47,6 +47,9 @@ describe("createUtoopackConfig", () => { proxy: [], cliShortcuts: true, }, + logging: { + browserToTerminal: "error", + }, server: { basePath: "/__evjs", runtime: { diff --git a/packages/bundler-utoopack/tests/multi-server-entry.test.ts b/packages/bundler-utoopack/tests/multi-server-entry.test.ts index 51d78c25..173567ab 100644 --- a/packages/bundler-utoopack/tests/multi-server-entry.test.ts +++ b/packages/bundler-utoopack/tests/multi-server-entry.test.ts @@ -141,6 +141,9 @@ function createResolvedConfig(): Parameters[0] { proxy: [], cliShortcuts: true, }, + logging: { + browserToTerminal: "error", + }, server: { basePath: "/__evjs", runtime: { diff --git a/packages/bundler-webpack/tests/create-config.test.ts b/packages/bundler-webpack/tests/create-config.test.ts index ab2b8c2d..15056e03 100644 --- a/packages/bundler-webpack/tests/create-config.test.ts +++ b/packages/bundler-webpack/tests/create-config.test.ts @@ -1222,6 +1222,9 @@ function createResolvedConfig( proxy: [], cliShortcuts: true, }, + logging: { + browserToTerminal: "error", + }, output: { client: "dist/client", server: "dist/server", diff --git a/packages/cli/tests/config.test.ts b/packages/cli/tests/config.test.ts index e8ad6c07..9bca921b 100644 --- a/packages/cli/tests/config.test.ts +++ b/packages/cli/tests/config.test.ts @@ -46,6 +46,7 @@ describe("CONFIG_DEFAULTS", () => { expect(CONFIG_DEFAULTS.serverPort).toBe(3001); expect(CONFIG_DEFAULTS.serverBasePath).toBe("/__evjs"); expect(CONFIG_DEFAULTS.crossOriginLoading).toBe("anonymous"); + expect(CONFIG_DEFAULTS.browserToTerminal).toBe("error"); }); it("is readonly", () => { @@ -61,6 +62,7 @@ describe("CONFIG_DEFAULTS", () => { outputServerDir: "dist/server", pageRoot: "./src/pages", mount: "#app", + browserToTerminal: "error", }); }); }); diff --git a/packages/ev/src/config/index.ts b/packages/ev/src/config/index.ts index 59cb7033..3be3aa10 100644 --- a/packages/ev/src/config/index.ts +++ b/packages/ev/src/config/index.ts @@ -1,4 +1,5 @@ export { + type BrowserToTerminal, type ClientTarget, CONFIG_DEFAULTS, type Config, @@ -14,6 +15,7 @@ export { definePageConfig, type ExtractInstalledPlugin, type InstalledPluginRegistry, + type LoggingConfig, merge, type OutputConfig, type PageAnchorMetadata, @@ -32,6 +34,7 @@ export { type ResolvedConfigRouteApplicationDocument, type ResolvedDevConfig, type ResolvedFrameworkConfig, + type ResolvedLoggingConfig, type ResolvedOutputConfig, type ResolvedPageRoutingConfig, type ResolvedPolyfillConfig, diff --git a/packages/ev/src/config/resolution.ts b/packages/ev/src/config/resolution.ts index e2de9b7b..58ce3ad3 100644 --- a/packages/ev/src/config/resolution.ts +++ b/packages/ev/src/config/resolution.ts @@ -65,6 +65,15 @@ export interface ResolvedDevConfig { cliShortcuts: boolean; } +/** Browser log forwarding levels supported during development. */ +export type BrowserToTerminal = boolean | "error" | "warn"; + +/** Resolved framework logging configuration (all defaults applied). */ +export interface ResolvedLoggingConfig { + /** Forward browser console output to the development terminal. */ + browserToTerminal: BrowserToTerminal; +} + /** Proxy rule for the dev server. */ export interface DevProxyRule { context: string[]; @@ -135,6 +144,8 @@ export interface ResolvedConfig { application?: ResolvedConfigRouteApplication; /** Client dev server options. */ dev: ResolvedDevConfig; + /** Framework logging options. */ + logging: ResolvedLoggingConfig; /** Server configuration. */ server: ResolvedServerConfig; /** Browser-to-server transport configuration. */ @@ -181,6 +192,12 @@ export interface Config { /** Client dev server options. */ dev?: DevConfig; + /** + * Framework logging options. Set to `false` to disable configurable logging. + * Essential CLI lifecycle messages and fatal diagnostics are unaffected. + */ + logging?: LoggingConfig | false; + /** Server configuration. */ server?: ServerConfig; @@ -249,6 +266,19 @@ export interface DevConfig { cliShortcuts?: boolean; } +/** Framework logging options. */ +export interface LoggingConfig { + /** + * Forward browser console output to the terminal while `ev dev` runs. + * + * - `false`: disable browser log forwarding + * - `true`: forward all standard browser console levels + * - `"warn"`: forward warnings and errors + * - `"error"`: forward errors only (default) + */ + browserToTerminal?: BrowserToTerminal; +} + /** Server configuration. */ export interface ServerConfig { /** @@ -549,6 +579,7 @@ export const CONFIG_DEFAULTS = { outputServerDir: "dist/server", pageRoot: CANONICAL_PAGE_ROUTE_ROOT, mount: "#app", + browserToTerminal: "error", } as const; const PUBLIC_ROOT_CONFIG_KEYS = new Set([ "conventions", @@ -556,6 +587,7 @@ const PUBLIC_ROOT_CONFIG_KEYS = new Set([ "polyfill", "output", "dev", + "logging", "server", "transport", "routing", @@ -591,6 +623,7 @@ const PUBLIC_DEV_CONFIG_KEYS = new Set([ "proxy", "cliShortcuts", ]); +const PUBLIC_LOGGING_CONFIG_KEYS = new Set(["browserToTerminal"]); const PUBLIC_SERVER_CONFIG_KEYS = new Set([ "basePath", "rsc", @@ -673,6 +706,7 @@ export function resolveConfig( const conventions = resolveConventionsConfig(config.conventions); const devConfig = resolveOptionalConfigRecord(config.dev, "dev"); validateDevConfigKeys(devConfig); + const loggingConfig = resolveLoggingConfig(config.logging); const serverConfig = resolveOptionalConfigRecord( config.server, "server", @@ -767,6 +801,7 @@ export function resolveConfig( proxy: resolveDevProxyRules(devConfig.proxy), cliShortcuts, }, + logging: loggingConfig, server: { basePath: serverBasePath, runtime: { @@ -1118,7 +1153,35 @@ function validateRootConfigKeys(config: Record): void { config, PUBLIC_ROOT_CONFIG_KEYS, "config", - "conventions, output, dev, server, transport, routing, application, bundler, or plugins", + "conventions, output, dev, logging, server, transport, routing, application, bundler, or plugins", + ); +} + +function resolveLoggingConfig( + logging: Config["logging"], +): ResolvedLoggingConfig { + if (logging === false) { + return { browserToTerminal: false }; + } + const loggingConfig = resolveOptionalConfigRecord( + logging, + "logging", + ); + validateLoggingConfigKeys(loggingConfig); + return { + browserToTerminal: resolveBrowserToTerminal( + loggingConfig.browserToTerminal, + ), + }; +} + +function resolveBrowserToTerminal(value: unknown): BrowserToTerminal { + if (value === undefined) return CONFIG_DEFAULTS.browserToTerminal; + if (typeof value === "boolean" || value === "error" || value === "warn") { + return value; + } + throw new Error( + '[evjs] logging.browserToTerminal must be a boolean, "error", or "warn" when provided.', ); } @@ -1701,6 +1764,15 @@ function validateDevConfigKeys(dev: DevConfig): void { ); } +function validateLoggingConfigKeys(logging: LoggingConfig): void { + assertKnownConfigKeys( + logging, + PUBLIC_LOGGING_CONFIG_KEYS, + "logging", + "browserToTerminal", + ); +} + function validateServerDevConfigKeys(dev: ServerDevConfig): void { assertKnownConfigKeys( dev, diff --git a/packages/ev/tests/config.test.ts b/packages/ev/tests/config.test.ts index bcc36adb..6fa398b1 100644 --- a/packages/ev/tests/config.test.ts +++ b/packages/ev/tests/config.test.ts @@ -480,6 +480,7 @@ describe("resolveConfig", () => { }); expect(resolved.dev.proxy).toEqual([]); expect(resolved.dev.cliShortcuts).toBe(true); + expect(resolved.logging).toEqual({ browserToTerminal: "error" }); }); it("resolves dev.cliShortcuts as a boolean", () => { @@ -500,6 +501,54 @@ describe("resolveConfig", () => { } }); + it("resolves logging.browserToTerminal with Next.js-compatible values", () => { + expect(resolveConfig({}).logging.browserToTerminal).toBe("error"); + expect( + resolveConfig({ logging: { browserToTerminal: "error" } }).logging + .browserToTerminal, + ).toBe("error"); + expect( + resolveConfig({ logging: { browserToTerminal: "warn" } }).logging + .browserToTerminal, + ).toBe("warn"); + expect( + resolveConfig({ logging: { browserToTerminal: true } }).logging + .browserToTerminal, + ).toBe(true); + expect( + resolveConfig({ logging: { browserToTerminal: false } }).logging + .browserToTerminal, + ).toBe(false); + expect(resolveConfig({ logging: false }).logging.browserToTerminal).toBe( + false, + ); + }); + + it("rejects invalid logging.browserToTerminal values", () => { + for (const browserToTerminal of ["verbose", 0, null, {}, []]) { + expect(() => + resolveConfig({ + logging: { browserToTerminal }, + } as unknown as Config), + ).toThrow( + '[evjs] logging.browserToTerminal must be a boolean, "error", or "warn" when provided.', + ); + } + }); + + it("strictly validates the logging config boundary", () => { + expect(() => resolveConfig({ logging: true } as unknown as Config)).toThrow( + "[evjs] logging must be a config object.", + ); + expect(() => + resolveConfig({ + logging: { browserToTerminal: "error", unknown: true }, + } as unknown as Config), + ).toThrow( + "[evjs] logging.unknown is not supported. Use browserToTerminal.", + ); + }); + it("accepts only plain config records at root and nested boundaries", () => { class ConfigRecord { readonly mode = "spa";