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 5 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
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
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'

// eslint-disable-next-line import/no-restricted-paths
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
74 changes: 74 additions & 0 deletions packages/nuxt/src/app/plugins/dev-server-logs.client.ts
@@ -0,0 +1,74 @@
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'

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())
danielroe marked this conversation as resolved.
Show resolved Hide resolved
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`)
})
danielroe marked this conversation as resolved.
Show resolved Hide resolved
}))

// Pass any unhandled logs to the client
nitroApp.hooks.hook('render:html', htmlContext => {
htmlContext.bodyAppend.unshift(`<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
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