Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nuxt): pass server logs to client #25936

Merged
merged 21 commits into from Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
96b8d40
feat(nuxt): pass + stream server logs to client
danielroe Feb 23, 2024
ea09686
fix: move hook type to correct place
danielroe Feb 23, 2024
1f69a05
fix(schema): don't enable in test mode
danielroe Feb 25, 2024
0dd300b
fix: add to HTML before other Nuxt scripts
danielroe Feb 25, 2024
dc01600
Merge remote-tracking branch 'origin/main' into feat/ssr-logs
danielroe Feb 25, 2024
622af41
Merge remote-tracking branch 'origin/main' into feat/ssr-logs
danielroe Feb 26, 2024
e8c5188
refactor: use new `createEventStream` helper
danielroe Feb 26, 2024
dbfa4cb
Merge remote-tracking branch 'origin/main' into feat/ssr-logs
danielroe Mar 15, 2024
aee91c0
Merge remote-tracking branch 'origin/main' into feat/ssr-logs
danielroe Mar 15, 2024
b588492
style: lint
danielroe Mar 15, 2024
10f8d23
fix: use original consola instance
danielroe Mar 15, 2024
fcbd195
feat: use asyncContext to associate logs with request
danielroe Mar 15, 2024
1ae9120
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 15, 2024
2e3f8d3
fix: remove sse endpoint and add nitro `dev:ssr-logs` hook
danielroe Mar 15, 2024
87e82ca
fix: add nuxt edge packages to ignore list
danielroe Mar 15, 2024
45d489c
Merge branch 'main' into feat/ssr-logs
danielroe Mar 15, 2024
0bfa8ad
fix: call restore on main consola instance
danielroe Mar 15, 2024
3c32946
fix: exclude vite-node logs
danielroe Mar 15, 2024
33176c0
chore: move filename to additional and only one line
Atinux Mar 15, 2024
ba70ecc
Update dev-server-logs.client.ts
Atinux Mar 15, 2024
896a818
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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>
pi0 marked this conversation as resolved.
Show resolved Hide resolved
'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