diff --git a/docs/3.api/6.advanced/1.hooks.md b/docs/3.api/6.advanced/1.hooks.md index e040c14e54..0980575a37 100644 --- a/docs/3.api/6.advanced/1.hooks.md +++ b/docs/3.api/6.advanced/1.hooks.md @@ -29,6 +29,7 @@ Hook | Arguments | Environment | Description `page:loading:start` | - | Client | Called when the `setup()` of the new page is running. `page:loading:end` | - | Client | Called after `page:finish` `page:transition:finish`| `pageComponent?` | Client | After page transition [onAfterLeave](https://vuejs.org/guide/built-ins/transition.html#javascript-hooks) event. +`dev:ssr-logs` | `logs` | Client | Called with an array of server-side logs that have been passed to the client (if `features.devLogs` is enabled). `page:view-transition:start` | `transition` | Client | Called after `document.startViewTransition` is called when [experimental viewTransition support is enabled](https://nuxt.com/docs/getting-started/transitions#view-transitions-api-experimental). ## Nuxt Hooks (build time) @@ -92,6 +93,7 @@ See [Nitro](https://nitro.unjs.io/guide/plugins#available-hooks) for all availab Hook | Arguments | Description | Types -----------------------|-----------------------|--------------------------------------|------------------ +`dev:ssr-logs` | `{ path, logs }` | Server | Called at the end of a request cycle with an array of server-side logs. `render:response` | `response, { event }` | Called before sending the response. | [response](https://github.com/nuxt/nuxt/blob/71ef8bd3ff207fd51c2ca18d5a8c7140476780c7/packages/nuxt/src/core/runtime/nitro/renderer.ts#L24), [event](https://github.com/unjs/h3/blob/f6ceb5581043dc4d8b6eab91e9be4531e0c30f8e/src/types.ts#L38) `render:html` | `html, { event }` | Called before constructing the HTML. | [html](https://github.com/nuxt/nuxt/blob/71ef8bd3ff207fd51c2ca18d5a8c7140476780c7/packages/nuxt/src/core/runtime/nitro/renderer.ts#L15), [event](https://github.com/unjs/h3/blob/f6ceb5581043dc4d8b6eab91e9be4531e0c30f8e/src/types.ts#L38) `render:island` | `islandResponse, { event, islandContext }` | Called before constructing the island HTML. | [islandResponse](https://github.com/nuxt/nuxt/blob/e50cabfed1984c341af0d0c056a325a8aec26980/packages/nuxt/src/core/runtime/nitro/renderer.ts#L28), [event](https://github.com/unjs/h3/blob/f6ceb5581043dc4d8b6eab91e9be4531e0c30f8e/src/types.ts#L38), [islandContext](https://github.com/nuxt/nuxt/blob/e50cabfed1984c341af0d0c056a325a8aec26980/packages/nuxt/src/core/runtime/nitro/renderer.ts#L38) diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index 4071bfc8b2..61aecaf95f 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -9,6 +9,7 @@ import type { SSRContext, createRenderer } from 'vue-bundle-renderer/runtime' import type { EventHandlerRequest, H3Event } from 'h3' import type { AppConfig, AppConfigInput, RuntimeConfig } from 'nuxt/schema' import type { RenderResponse } from 'nitropack' +import type { LogObject } from 'consola' import type { MergeHead, VueHeadClient } from '@unhead/vue' import type { NuxtIslandContext } from '../app/types' @@ -40,6 +41,7 @@ export interface RuntimeNuxtHooks { 'app:chunkError': (options: { error: any }) => HookResult 'app:data:refresh': (keys?: string[]) => HookResult 'app:manifest:update': (meta?: NuxtAppManifestMeta) => HookResult + 'dev:ssr-logs': (logs: LogObject[]) => void | Promise 'link:prefetch': (link: string) => HookResult 'page:start': (Component?: VNode) => HookResult 'page:finish': (Component?: VNode) => HookResult diff --git a/packages/nuxt/src/app/plugins/dev-server-logs.client.ts b/packages/nuxt/src/app/plugins/dev-server-logs.client.ts new file mode 100644 index 0000000000..9cfc39e858 --- /dev/null +++ b/packages/nuxt/src/app/plugins/dev-server-logs.client.ts @@ -0,0 +1,62 @@ +import { consola, createConsola } from 'consola' +import type { LogObject } from 'consola' + +import { defineNuxtPlugin } from '../nuxt' + +// @ts-expect-error virtual file +import { devLogs, devRootDir } from '#build/nuxt.config.mjs' + +export default defineNuxtPlugin((nuxtApp) => { + // Show things in console + if (devLogs !== 'silent') { + const logger = createConsola({ + formatOptions: { + colors: true, + date: true + } + }) + const hydrationLogs = new Set() + consola.wrapConsole() + consola.addReporter({ + log (logObj) { + try { + hydrationLogs.add(JSON.stringify(logObj.args)) + } catch { + // silently ignore - the worst case is a user gets log twice + } + } + }) + nuxtApp.hook('dev:ssr-logs', (logs) => { + for (const log of logs) { + // deduplicate so we don't print out things that are logged on client + if (!hydrationLogs.size || !hydrationLogs.has(JSON.stringify(log.args))) { + logger.log(normalizeServerLog({ ...log })) + } + } + }) + + nuxtApp.hooks.hook('app:suspense:resolve', () => consola.restoreAll()) + nuxtApp.hooks.hookOnce('dev:ssr-logs', () => hydrationLogs.clear()) + } + + // pass SSR logs after hydration + nuxtApp.hooks.hook('app:suspense:resolve', async () => { + if (window && window.__NUXT_LOGS__) { + await nuxtApp.hooks.callHook('dev:ssr-logs', window.__NUXT_LOGS__) + } + }) +}) + +function normalizeFilenames (stack?: string) { + stack = stack?.split('\n')[0] || '' + stack = stack.replace(`${devRootDir}/`, '') + stack = stack.replace(/:\d+:\d+\)?$/, '') + return stack +} + +function normalizeServerLog (log: LogObject) { + log.additional = normalizeFilenames(log.stack as string) + log.tag = 'ssr' + delete log.stack + return log +} diff --git a/packages/nuxt/src/app/types/augments.d.ts b/packages/nuxt/src/app/types/augments.d.ts index e9ea30b927..1f9a89e8bf 100644 --- a/packages/nuxt/src/app/types/augments.d.ts +++ b/packages/nuxt/src/app/types/augments.d.ts @@ -1,4 +1,5 @@ import type { UseHeadInput } from '@unhead/vue' +import type { LogObject } from 'consola' import type { NuxtApp, useNuxtApp } from '../nuxt' interface NuxtStaticBuildFlags { @@ -17,6 +18,7 @@ declare global { interface ImportMeta extends NuxtStaticBuildFlags {} interface Window { + __NUXT_LOGS__?: LogObject[] __NUXT__?: Record useNuxtApp?: typeof useNuxtApp } diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index 6efea3bb3c..0b83c3885c 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -1,7 +1,7 @@ import { dirname, join, normalize, relative, resolve } from 'pathe' import { createDebugger, createHooks } from 'hookable' import type { LoadNuxtOptions } from '@nuxt/kit' -import { addBuildPlugin, addComponent, addPlugin, addRouteMiddleware, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, logger, nuxtCtx, resolveAlias, resolveFiles, resolvePath, tryResolveModule, useNitro } from '@nuxt/kit' +import { addBuildPlugin, addComponent, addPlugin, addRouteMiddleware, addServerPlugin, addVitePlugin, addWebpackPlugin, installModule, loadNuxtConfig, logger, nuxtCtx, resolveAlias, resolveFiles, resolvePath, tryResolveModule, useNitro } from '@nuxt/kit' import { resolvePath as _resolvePath } from 'mlly' import type { Nuxt, NuxtHooks, NuxtOptions } from 'nuxt/schema' import { resolvePackageJSON } from 'pkg-types' @@ -175,6 +175,19 @@ async function initNuxt (nuxt: Nuxt) { addPlugin(resolve(nuxt.options.appDir, 'plugins/check-if-layout-used')) } + if (nuxt.options.dev && nuxt.options.features.devLogs) { + addPlugin(resolve(nuxt.options.appDir, 'plugins/dev-server-logs.client')) + addServerPlugin(resolve(distDir, 'core/runtime/nitro/dev-server-logs')) + nuxt.options.nitro = defu(nuxt.options.nitro, { + externals: { + inline: [/#internal\/dev-server-logs-options/] + }, + virtual: { + '#internal/dev-server-logs-options': () => `export const rootDir = ${JSON.stringify(nuxt.options.rootDir)};` + } + }) + } + // Transform initial composable call within ``) + }) +} + +const EXCLUDE_TRACE_RE = /^.*at.*(\/node_modules\/(.*\/)?(nuxt|nuxt-nightly|nuxt-edge|nuxt3|consola|@vue)\/.*|core\/runtime\/nitro.*)$\n?/gm +function getStack () { + // Pass along stack traces if needed (for error and warns) + // eslint-disable-next-line unicorn/error-message + const stack = new Error() + Error.captureStackTrace(stack) + return stack.stack?.replace(EXCLUDE_TRACE_RE, '').replace(/^Error.*\n/, '') || '' +} + +const FILENAME_RE = /at.*\(([^:)]+)[):]/ +const FILENAME_RE_GLOBAL = /at.*\(([^)]+)\)/g +function extractFilenameFromStack (stacktrace: string) { + return stacktrace.match(FILENAME_RE)?.[1].replace(withTrailingSlash(rootDir), '') +} +function normalizeFilenames (stacktrace: string) { + // remove line numbers and file: protocol - TODO: sourcemap support for line numbers + return stacktrace.replace(FILENAME_RE_GLOBAL, (match, filename) => match.replace(filename, filename.replace('file:///', '/').replace(/:.*$/, ''))) +} + +function onConsoleLog (callback: (log: LogObject) => void) { + consola.addReporter({ + log (logObj) { + callback(logObj) + } + }) + consola.wrapConsole() +} diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index c3f7cfa49d..52cb7a460d 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -228,6 +228,7 @@ export const nitroSchemaTemplate: NuxtTemplate = { import type { RuntimeConfig } from 'nuxt/schema' import type { H3Event } from 'h3' +import type { LogObject } from 'consola' import type { NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext } from 'nuxt/app' declare module 'nitropack' { @@ -245,6 +246,7 @@ declare module 'nitropack' { experimentalNoScripts?: boolean } interface NitroRuntimeHooks { + 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise 'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise 'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise } @@ -388,6 +390,7 @@ export const nuxtConfigTemplate: NuxtTemplate = { `export const selectiveClient = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && Boolean(ctx.nuxt.options.experimental.componentIslands.selectiveClient)}`, `export const devPagesDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.dir.pages) : 'null'}`, `export const devRootDir = ${ctx.nuxt.options.dev ? JSON.stringify(ctx.nuxt.options.rootDir) : 'null'}`, + `export const devLogs = ${JSON.stringify(ctx.nuxt.options.features.devLogs)}`, `export const nuxtLinkDefaults = ${JSON.stringify(ctx.nuxt.options.experimental.defaults.nuxtLink)}`, `export const asyncDataDefaults = ${JSON.stringify(ctx.nuxt.options.experimental.defaults.useAsyncData)}`, `export const fetchDefaults = ${JSON.stringify(fetchDefaults)}`, diff --git a/packages/nuxt/types.d.mts b/packages/nuxt/types.d.mts index a2d0de646c..98771c18e2 100644 --- a/packages/nuxt/types.d.mts +++ b/packages/nuxt/types.d.mts @@ -5,6 +5,7 @@ import type { DefineNuxtConfig } from 'nuxt/config' import type { RuntimeConfig, SchemaDefinition } from 'nuxt/schema' import type { H3Event } from 'h3' import type { NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext } from './dist/app/types.js' +import type { LogObject } from 'consola' declare global { const defineNuxtConfig: DefineNuxtConfig @@ -27,6 +28,7 @@ declare module 'nitropack' { experimentalNoScripts?: boolean } interface NitroRuntimeHooks { + 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise 'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise 'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise } diff --git a/packages/nuxt/types.d.ts b/packages/nuxt/types.d.ts index 7f9075ad82..8b9c9b9118 100644 --- a/packages/nuxt/types.d.ts +++ b/packages/nuxt/types.d.ts @@ -2,6 +2,7 @@ import type { DefineNuxtConfig } from 'nuxt/config' import type { RuntimeConfig, SchemaDefinition } from 'nuxt/schema' import type { H3Event } from 'h3' +import type { LogObject } from 'consola' import type { NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext } from './dist/app/types' export * from './dist/index' @@ -27,6 +28,7 @@ declare module 'nitropack' { experimentalNoScripts?: boolean } interface NitroRuntimeHooks { + 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise 'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise 'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise } diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 47d486495a..6c835355e3 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -52,6 +52,21 @@ export default defineUntypedSchema({ } }, + /** + * Stream server logs to the client as you are developing. These logs can + * be handled in the `dev:ssr-logs` hook. + * + * If set to `silent`, the logs will not be printed to the browser console. + * @type {boolean | 'silent'} + */ + devLogs: { + async $resolve (val, get) { + if (val !== undefined) { return val } + const [isDev, isTest] = await Promise.all([get('dev'), get('test')]) + return isDev && !isTest + } + }, + /** * Turn off rendering of Nuxt scripts and JS resource hints. * You can also disable scripts more granularly within `routeRules`. diff --git a/packages/schema/src/config/typescript.ts b/packages/schema/src/config/typescript.ts index 8c9eb2c2d0..7569339220 100644 --- a/packages/schema/src/config/typescript.ts +++ b/packages/schema/src/config/typescript.ts @@ -32,7 +32,7 @@ export default defineUntypedSchema({ */ hoist: { $resolve: (val) => { - const defaults = ['nitropack', 'defu', 'h3', '@unhead/vue', 'vue', 'vue-router', '@nuxt/schema'] + const defaults = ['nitropack', 'defu', 'h3', '@unhead/vue', 'vue', 'vue-router', 'consola', '@nuxt/schema'] return val === false ? [] : (Array.isArray(val) ? val.concat(defaults) : defaults) } },