Skip to content

Commit

Permalink
feat(nuxt): pass + stream server logs to client
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe committed Feb 23, 2024
1 parent 5e5e969 commit 96b8d40
Show file tree
Hide file tree
Showing 7 changed files with 210 additions and 1 deletion.
1 change: 1 addition & 0 deletions docs/3.api/6.advanced/1.hooks.md
Expand Up @@ -28,6 +28,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).

## Nuxt Hooks (build time)

Expand Down
80 changes: 80 additions & 0 deletions packages/nuxt/src/app/plugins/dev-server-logs.client.ts
@@ -0,0 +1,80 @@
import { consola, createConsola } from 'consola'
import type { LogObject } from 'consola'
import { isAbsolute } from 'pathe'

import { defineNuxtPlugin } from '../nuxt'

// @ts-expect-error virtual file
import { devLogs, devRootDir } from '#build/nuxt.config.mjs'

declare module '#app' {
interface RuntimeNuxtHooks {
'dev:ssr-logs': (logs: LogObject[]) => void | Promise<void>
}
}

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.wrapAll()
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', () => logger.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__)
}
})

// initialise long-running SSE connection
const source = new EventSource('/_nuxt_logs')
source.onmessage = (event) => {
const log = JSON.parse(event.data) as LogObject
log.date = new Date(log.date)
nuxtApp.hooks.callHook('dev:ssr-logs', [log])
}
})

function normalizeFilenames (stack?: string) {
return stack?.replace(/at.*\(([^)]+)\)/g, (match, filename) => {
if (!isAbsolute(filename)) { return match }
// TODO: normalise file names for clickable links in console
return match.replace(filename, filename.replace(devRootDir, ''))
})
}

function normalizeServerLog (log: LogObject) {
if (log.type === 'error' || log.type === 'warn') {
log.additional = normalizeFilenames(log.stack as string)
}
log.tag = `[ssr]${log.filename ? ` ${log.filename}` : ''}${log.tag || ''}`
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 { 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 type { Nuxt, NuxtHooks, NuxtOptions } from 'nuxt/schema'

import escapeRE from 'escape-string-regexp'
Expand Down Expand Up @@ -158,6 +158,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
101 changes: 101 additions & 0 deletions packages/nuxt/src/core/runtime/nitro/dev-server-logs.ts
@@ -0,0 +1,101 @@
import type { LogObject } from 'consola'
import { createConsola } from 'consola'
import devalue from '@nuxt/devalue'
import { createHooks } from 'hookable'
import { defineEventHandler, setHeaders, setResponseStatus } from 'h3'
import { withTrailingSlash } from 'ufo'

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

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

const originalConsole = {
log: console.log,
warn: console.warn,
info: console.info,
error: console.error,
}

export default (nitroApp: NitroApp) => {
const hooks = createHooks<{ log: (data: any) => void }>()
const logs: LogObject[] = []

onConsoleLog((_log) => {
const stack = getStack()

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
logs.push(log)
// send log messages to client via SSE
hooks.callHook('log', log)
})

// Add SSE endpoint for streaming logs to the client
nitroApp.router.add('/_nuxt_logs', defineEventHandler(async (event) => {
setResponseStatus(event, 200)
setHeaders(event, {
'cache-control': 'no-cache',
'connection': 'keep-alive',
'content-type': 'text/event-stream'
})

// Let Nitro know the connection is opened
event._handled = true

let counter = 0

hooks.hook('log', data => {
event.node.res.write(`id: ${++counter}\n`)
event.node.res.write(`data: ${JSON.stringify(data)}\n\n`)
})
}))

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

const EXCLUDE_TRACE_RE = new RegExp('^.*at.*(\\/node_modules\\/(.*\\/)?(nuxt|consola|@vue)\\/.*|core\\/runtime\\/nitro.*)$\\n?', 'gm')
function getStack () {
// Pass along stack traces if needed (for error and warns)
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) {
const logger = createConsola({
reporters: [
{
log (logObj) {
// Don't swallow log messages in console - is there a better way to do this @pi0?
// TODO: display (clickable) filename in server log as well when we use consola for this
(originalConsole[logObj.type as 'log'] || originalConsole.log)(...logObj.args)

callback(logObj)
},
}
]
})
logger.wrapAll()
}
1 change: 1 addition & 0 deletions packages/nuxt/src/core/templates.ts
Expand Up @@ -384,6 +384,7 @@ export const nuxtConfigTemplate: NuxtTemplate = {
`export const selectiveClient = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && 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
11 changes: 11 additions & 0 deletions packages/schema/src/config/experimental.ts
Expand Up @@ -52,6 +52,17 @@ export default defineUntypedSchema({
}
},

/**
* Display logs on the client console that are normally only shown in the server console.
*
* This also enables an ongoing stream of server logs in the browser console as you are developing.
*
* If set to `silent`, the logs will be streamed and you can handle them yourself with the `dev:ssr-logs` hook,
* but they will not be shown in the browser console.
* @type {boolean | 'silent'}
*/
devLogs: true,

/**
* Turn off rendering of Nuxt scripts and JS resource hints.
* You can also disable scripts more granularly within `routeRules`.
Expand Down

0 comments on commit 96b8d40

Please sign in to comment.