Skip to content

Commit

Permalink
feat(nuxt): pass server logs to client (#25936)
Browse files Browse the repository at this point in the history
Co-authored-by: Sébastien Chopin <[email protected]>
  • Loading branch information
danielroe and Atinux committed Mar 15, 2024
1 parent 5be9253 commit e272b2f
Show file tree
Hide file tree
Showing 11 changed files with 186 additions and 2 deletions.
2 changes: 2 additions & 0 deletions docs/3.api/6.advanced/1.hooks.md
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions packages/nuxt/src/app/nuxt.ts
Expand Up @@ -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'
Expand Down Expand Up @@ -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<void>
'link:prefetch': (link: string) => HookResult
'page:start': (Component?: VNode) => HookResult
'page:finish': (Component?: VNode) => HookResult
Expand Down
62 changes: 62 additions & 0 deletions 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<string>()
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
}
2 changes: 2 additions & 0 deletions 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 {
Expand All @@ -17,6 +18,7 @@ declare global {
interface ImportMeta extends NuxtStaticBuildFlags {}

interface Window {
__NUXT_LOGS__?: LogObject[]
__NUXT__?: Record<string, any>
useNuxtApp?: typeof useNuxtApp
}
Expand Down
15 changes: 14 additions & 1 deletion 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'
Expand Down Expand Up @@ -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 `<script setup>` to preserve context
if (nuxt.options.experimental.asyncContext) {
addBuildPlugin(AsyncContextInjectionPlugin(nuxt))
Expand Down
81 changes: 81 additions & 0 deletions packages/nuxt/src/core/runtime/nitro/dev-server-logs.ts
@@ -0,0 +1,81 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import type { LogObject } from 'consola'
import { consola } from 'consola'
import devalue from '@nuxt/devalue'
import type { H3Event } from 'h3'
import { withTrailingSlash } from 'ufo'
import { getContext } from 'unctx'

import type { NitroApp } from '#internal/nitro/app'

// @ts-expect-error virtual file
import { rootDir } from '#internal/dev-server-logs-options'

interface NuxtDevAsyncContext {
logs: LogObject[]
event: H3Event
}

const asyncContext = getContext<NuxtDevAsyncContext>('nuxt-dev', { asyncContext: true, AsyncLocalStorage })

export default (nitroApp: NitroApp) => {
const handler = nitroApp.h3App.handler
nitroApp.h3App.handler = (event) => {
return asyncContext.callAsync({ logs: [], event }, () => handler(event))
}

onConsoleLog((_log) => {
const ctx = asyncContext.use()
const stack = getStack()
if (stack.includes('runtime/vite-node.mjs')) { return }

const log = {
..._log,
// Pass along filename to allow the client to display more info about where log comes from
filename: extractFilenameFromStack(stack),
// Clean up file names in stack trace
stack: normalizeFilenames(stack)
}

// retain log to be include in the next render
ctx.logs.push(log)
})

nitroApp.hooks.hook('afterResponse', () => {
const ctx = asyncContext.use()
return nitroApp.hooks.callHook('dev:ssr-logs', { logs: ctx.logs, path: ctx.event.path })
})

// Pass any logs to the client
nitroApp.hooks.hook('render:html', (htmlContext) => {
htmlContext.bodyAppend.unshift(`<script>window.__NUXT_LOGS__ = ${devalue(asyncContext.use().logs)}</script>`)
})
}

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()
}
3 changes: 3 additions & 0 deletions packages/nuxt/src/core/templates.ts
Expand Up @@ -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' {
Expand All @@ -245,6 +246,7 @@ declare module 'nitropack' {
experimentalNoScripts?: boolean
}
interface NitroRuntimeHooks {
'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void>
'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void>
}
Expand Down Expand Up @@ -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)}`,
Expand Down
2 changes: 2 additions & 0 deletions packages/nuxt/types.d.mts
Expand Up @@ -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
Expand All @@ -27,6 +28,7 @@ declare module 'nitropack' {
experimentalNoScripts?: boolean
}
interface NitroRuntimeHooks {
'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void>
'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void>
}
Expand Down
2 changes: 2 additions & 0 deletions packages/nuxt/types.d.ts
Expand Up @@ -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'
Expand All @@ -27,6 +28,7 @@ declare module 'nitropack' {
experimentalNoScripts?: boolean
}
interface NitroRuntimeHooks {
'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void>
'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void>
}
Expand Down
15 changes: 15 additions & 0 deletions packages/schema/src/config/experimental.ts
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion packages/schema/src/config/typescript.ts
Expand Up @@ -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)
}
},
Expand Down

0 comments on commit e272b2f

Please sign in to comment.